diff --git a/.omo/run-continuation/ses_1a0c1bc8affexjd7idgHFAovWT.json b/.omo/run-continuation/ses_1a0c1bc8affexjd7idgHFAovWT.json new file mode 100644 index 0000000..7d3a514 --- /dev/null +++ b/.omo/run-continuation/ses_1a0c1bc8affexjd7idgHFAovWT.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_1a0c1bc8affexjd7idgHFAovWT", + "updatedAt": "2026-05-25T13:49:53.610Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-05-25T13:49:53.610Z" + } + } +} \ No newline at end of file diff --git a/agents_skills.txt b/agents_skills.txt new file mode 100644 index 0000000..9aa92f6 --- /dev/null +++ b/agents_skills.txt @@ -0,0 +1,28 @@ +aposd +aposd-audit +aposd-critique +brainstorming +dispatching-parallel-agents +enhance-prompt +executing-plans +find-skills +finishing-a-development-branch +gh-cli +git-commit +impeccable +karpathy-guidelines +react-components +receiving-code-review +repo-roast +requesting-code-review +shadcn-ui +stitch-design +stitch-loop +subagent-driven-development +systematic-debugging +test-driven-development +using-git-worktrees +using-superpowers +verification-before-completion +writing-plans +writing-skills diff --git a/docs/authoring.md b/docs/authoring.md index fa6b0b6..09168d3 100644 --- a/docs/authoring.md +++ b/docs/authoring.md @@ -26,7 +26,7 @@ do, and what to avoid, so the work you do for Claude Code translates cleanly eve |---|---|---|---| | `agents/.md` | `name`, `description` | `model`, optional `tools:`, optional `color:` | `tools:` allowlist becomes a per-harness permission block where supported, dropped otherwise. | | `skills//SKILL.md` | `name`, `description` | (none) | Other Anthropic SKILL.md fields work on Claude Code only. | -| `commands/.md` | `description` | `argument-hint:` | Codex converts these to skills (it deprecated `~/.codex/prompts/`). | +| `commands/.md` | `description` | `argument-hint:` | Codex converts these to skills; Copilot emits `.copilot/commands//.md` slash-command prompts. | **Description triggers.** Include a recognized phrase: `Use when …`, `Use this skill when …`, `Use PROACTIVELY when …`, `Use after …`, `Trigger when …`, `Auto-loads when …`. The @@ -141,7 +141,7 @@ Things that work in Claude Code but degrade across harnesses: | Hooks (`hooks:` frontmatter) | Only Claude Code and OpenCode (via TS plugins). | | `color:` on agents | Cosmetic; dropped everywhere except Claude Code. | | Per-agent tool allowlist | Honored only on Claude Code/Gemini/OpenCode. Cursor and Codex have coarser models. | -| Slash commands | Codex converts to skills. Gemini transpiles to TOML. | +| Slash commands | Codex converts to skills. Gemini transpiles to TOML. Copilot emits `.copilot/commands/` prompt files. | | Marketplace registry | Only Claude Code and Cursor have one. Gemini installs by URL; Codex/OpenCode have no marketplace. | When you must use a feature with no equivalent, the `harness_portability` lint won't fire diff --git a/docs/usage.md b/docs/usage.md index 08c4885..f647e3d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -70,8 +70,7 @@ Claude Code automatically selects and coordinates the appropriate agents based o | Command | Description | | ----------------------------------- | -------------------------- | -| `/comprehensive-review:full-review` | Multi-perspective analysis | -| `/comprehensive-review:pr-enhance` | Enhance pull requests | +| `/comprehensive-review:full-review` | Run the comprehensive review workflow | ### Debugging & Troubleshooting diff --git a/gemini_skills.txt b/gemini_skills.txt new file mode 100644 index 0000000..7225a0f --- /dev/null +++ b/gemini_skills.txt @@ -0,0 +1,14 @@ +brainstorming +dispatching-parallel-agents +executing-plans +finishing-a-development-branch +receiving-code-review +requesting-code-review +subagent-driven-development +systematic-debugging +test-driven-development +using-git-worktrees +using-superpowers +verification-before-completion +writing-plans +writing-skills diff --git a/tools/adapters/base.py b/tools/adapters/base.py index a0df045..e68985f 100644 --- a/tools/adapters/base.py +++ b/tools/adapters/base.py @@ -466,6 +466,7 @@ class HarnessAdapter(ABC): "WebFetch": "fetch", "WebSearch": "search", "TodoWrite": "todo", + "AskUserQuestion": "question", } out = body for camel, replacement in replacements.items(): diff --git a/tools/adapters/capabilities.py b/tools/adapters/capabilities.py index 967a405..1157b2b 100644 --- a/tools/adapters/capabilities.py +++ b/tools/adapters/capabilities.py @@ -94,7 +94,7 @@ CAPABILITIES: dict[str, Capability] = { display_name="GitHub Copilot", skills_native=True, agents_native=True, - commands_native=False, + commands_native=True, plugin_marketplace=False, parallel_agents=False, tool_allowlist_per_agent=True, @@ -107,7 +107,7 @@ CAPABILITIES: dict[str, Capability] = { skill_body_max_bytes=_NO_CAP, tool_name_case="lowercase", bare_model_aliases=False, - notes="Emits Markdown agent profiles to .github/agents/ and SKILL.md skills to .github/skills/. Copilot discovers from any clone (files are committed). Also discovers from ~/.copilot/agents/ (user-level) and .github-private/ (org-level).", + notes="Emits Markdown agent profiles to .copilot/agents/, SKILL.md skills to .copilot/skills/, and slash-command prompt files to .copilot/commands/. Copilot discovers from the repo or from ~/.copilot/ at user level.", ), "cursor": Capability( harness_id="cursor", diff --git a/tools/generate.py b/tools/generate.py index 43c729e..15e2563 100644 --- a/tools/generate.py +++ b/tools/generate.py @@ -34,7 +34,7 @@ _HARNESS_TARGETS = { "cursor": [".cursor", ".cursor-plugin"], "opencode": [".opencode", "opencode.json"], "gemini": ["commands", "agents", "skills"], - "copilot": [".copilot/agents", ".copilot/skills"], + "copilot": [".copilot/agents", ".copilot/skills", ".copilot/commands"], } @@ -165,6 +165,9 @@ def prune_orphans(harness_id: str, output_root: Path, written: set[Path]) -> lis d = output_root / ".copilot" / sub if d.is_dir(): candidates.extend(p for p in d.rglob("*") if p.is_file()) + d = output_root / ".copilot" / "commands" + if d.is_dir(): + candidates.extend(p for p in d.rglob("*") if p.is_file()) elif harness_id == "cursor": # Both .cursor-plugin/plugins/*.json and .cursor/rules/*.mdc are adapter outputs. for sub_path in ( diff --git a/tools/install_copilot.py b/tools/install_copilot.py index 44c514f..709f204 100644 --- a/tools/install_copilot.py +++ b/tools/install_copilot.py @@ -13,6 +13,7 @@ GENERATED_ROOT = REPO_ROOT / ".copilot" ARTIFACT_GLOBS = { "agents": "*.agent.md", "skills": "*", + "commands": "*", } @@ -58,7 +59,9 @@ def _generated_artifacts(repo_root: Path) -> list[tuple[str, Path]]: for src in sorted(source_dir.glob(pattern)): if subdir == "skills" and not src.is_dir(): continue - if subdir != "skills" and not src.is_file(): + if subdir in {"agents"} and not src.is_file(): + continue + if subdir in {"commands"} and not src.is_dir(): continue artifacts.append((subdir, src.resolve())) return artifacts @@ -100,8 +103,15 @@ def install( return report for subdir, src in artifacts: - dst = config_dir / subdir / src.name - _link_one(src, dst, force=force, report=report) + if subdir == "commands": + # For commands, extract plugin name and symlink to ~/.copilot//commands/ + # src is like .copilot/commands/comprehensive-review, symlink to ~/.copilot/comprehensive-review/commands + plugin_name = src.name + dst = config_dir / plugin_name / "commands" + _link_one(src, dst, force=force, report=report) + else: + dst = config_dir / subdir / src.name + _link_one(src, dst, force=force, report=report) return report diff --git a/tools/tests/test_adapters.py b/tools/tests/test_adapters.py index a285723..70f71ef 100644 --- a/tools/tests/test_adapters.py +++ b/tools/tests/test_adapters.py @@ -1015,6 +1015,22 @@ class TestCopilotAdapter: assert fm["description"] == "Use when greeting users." assert "# Hello" in body + def test_emits_command_prompt_files(self, synthetic_plugin: PluginSource, output_root: Path): + CopilotAdapter(output_root=output_root).emit_plugin(synthetic_plugin) + + entry = output_root / ".copilot" / "commands" / "demo" / "index.md" + cmd = output_root / ".copilot" / "commands" / "demo" / "say-hi.md" + + assert entry.is_file() + assert cmd.is_file() + + entry_fm, entry_body = parse_frontmatter(entry.read_text()) + cmd_fm, cmd_body = parse_frontmatter(cmd.read_text()) + assert entry_fm["description"] == "Demo plugin for tests" + assert "/demo:say-hi" in entry_body + assert cmd_fm["description"] == "Send a greeting" + assert "Greet the user named $ARGUMENTS." in cmd_body + def test_emit_global_returns_empty( self, synthetic_plugin: PluginSource, output_root: Path ): diff --git a/tools/tests/test_install_copilot.py b/tools/tests/test_install_copilot.py index bbd2498..b062525 100644 --- a/tools/tests/test_install_copilot.py +++ b/tools/tests/test_install_copilot.py @@ -10,10 +10,14 @@ from tools.install_copilot import default_config_dir, install, uninstall def _write_generated_copilot(repo_root: Path) -> None: agents = repo_root / ".copilot" / "agents" skills = repo_root / ".copilot" / "skills" / "demo-hello" + commands = repo_root / ".copilot" / "commands" / "demo" agents.mkdir(parents=True) skills.mkdir(parents=True) + commands.mkdir(parents=True) (agents / "demo__agent.agent.md").write_text("agent\n") (skills / "SKILL.md").write_text("---\nname: demo-hello\n---\n\nBody.\n") + (commands / "index.md").write_text("---\ndescription: demo\n---\n\nEntry.\n") + (commands / "say-hi.md").write_text("---\ndescription: hi\n---\n\nHi.\n") def test_default_config_dir_prefers_copilot_config_dir(tmp_path: Path): @@ -39,11 +43,12 @@ def test_install_creates_idempotent_symlinks(tmp_path: Path): second = install(repo_root=repo_root, config_dir=config_dir) assert first.ok - assert first.linked == 2 + assert first.linked == 3 assert second.ok - assert second.unchanged == 2 + assert second.unchanged == 3 assert (config_dir / "agents" / "demo__agent.agent.md").is_symlink() assert (config_dir / "skills" / "demo-hello").is_symlink() + assert (config_dir / "commands" / "demo").is_symlink() def test_install_refuses_to_overwrite_real_files(tmp_path: Path): @@ -95,7 +100,7 @@ def test_uninstall_removes_only_repo_owned_symlinks(tmp_path: Path): report = uninstall(repo_root=repo_root, config_dir=config_dir) assert report.ok - assert report.removed == 2 + assert report.removed == 3 assert not (config_dir / "agents" / "demo__agent.agent.md").exists() assert unrelated.is_symlink() assert real_file.read_text() == "user\n" diff --git a/tools/tests/test_round_trip.py b/tools/tests/test_round_trip.py index ac63247..23b8297 100644 --- a/tools/tests/test_round_trip.py +++ b/tools/tests/test_round_trip.py @@ -297,6 +297,26 @@ class TestCopilotRoundTrip: f"skill count mismatch: source={_source_skill_count()} copilot={n}" ) + def test_copilot_command_count_matches_source(self): + n = len( + [ + p + for p in (WORKTREE / ".copilot" / "commands").rglob("*.md") + if p.name != "index.md" + ] + ) + assert n == _source_command_count(), ( + f"command count mismatch: source={_source_command_count()} copilot={n}" + ) + + def test_copilot_command_entrypoints_exist_for_every_plugin(self): + missing = [] + for plugin_name in list_plugins(): + entry = WORKTREE / ".copilot" / "commands" / plugin_name / "index.md" + if not entry.is_file(): + missing.append(plugin_name) + assert not missing, f"missing Copilot command entrypoints for: {sorted(missing)}" + def test_every_copilot_agent_has_required_frontmatter(self): required = {"name", "description"} problems = [] diff --git a/tools/validate_generated.py b/tools/validate_generated.py index 2ba1d55..adcb58e 100644 --- a/tools/validate_generated.py +++ b/tools/validate_generated.py @@ -559,9 +559,10 @@ def validate_gemini(report: Report) -> None: def validate_copilot(report: Report) -> None: - """Validate Copilot agent markdown files under WORKTREE/.copilot/agents. + """Validate Copilot agent, skill, and command markdown files under WORKTREE/.copilot. - Checks that every .agent.md file has valid frontmatter with required fields. + Checks that generated agent, skill, and command markdown files have valid + frontmatter and the minimum metadata Copilot needs to discover them. """ candidate_roots = [WORKTREE / ".copilot"] found_any = False @@ -679,6 +680,39 @@ def validate_copilot(report: Report) -> None: remediation="Set `description` to a non-empty string in the skill frontmatter.", ) + commands_dir = WORKTREE / ".copilot" / "commands" + if commands_dir.is_dir(): + found_any = True + for command_md in commands_dir.rglob("*.md"): + content = command_md.read_text(encoding="utf-8") + fm, body = parse_frontmatter(content) + if not fm: + report.add( + severity="error", + harness="copilot", + path=command_md, + message="missing or invalid frontmatter", + remediation="Regenerate via `make generate HARNESS=copilot`.", + ) + continue + description = fm.get("description") + if not isinstance(description, str) or not description.strip(): + report.add( + severity="error", + harness="copilot", + path=command_md, + message="missing required `description` field in frontmatter", + remediation="Copilot command prompt files need a non-empty description.", + ) + if command_md.name != "index.md" and not body.strip(): + report.add( + severity="warning", + harness="copilot", + path=command_md, + message="command body is empty", + remediation="Keep the prompt body in the source command markdown.", + ) + if not found_any: return