Files
v8/agents/scripts/install_for_copilot_cli.py
Hannes Payer 72302c22b7 [agents] Simplify orchestration
Remove 'Orchestrator-Only' mandates, static subagent personas
(agents/agents/*), and orchestration/adapter skills
(orchestrator, env-abstraction, subagent-env-passing) so the main
agent can investigate C++ directly while delegating genuinely parallel
work to built-in subagents (self and research-google).

- Consolidate framework.md into execution-constraints.md and switch
  debugging.md to trigger: model_decision.
- Update install scripts and blocklists for removed agent personas and
  orchestration skills/rules.

Eval: https://chromium-review.git.corp.google.com/c/v8/v8/+/8445039

TAG=agy

Change-Id: Ief8903507223e10f01d66bcf406d9cb4b0fc9438
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8451031
Reviewed-by: Toon Verwaest <verwaest@chromium.org>
Reviewed-by: Leszek Swirski <leszeks@chromium.org>
Commit-Queue: Hannes Payer <hpayer@chromium.org>
Cr-Commit-Position: refs/heads/main@{#110057}
2026-09-24 01:14:36 -07:00

354 lines
13 KiB
Python

#!/usr/bin/env vpython3
# Copyright 2026 the V8 project authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Install local GitHub Copilot CLI adapters for V8 agent files.
Generated files are ignored by Git. Pass --force to refresh files created by a
previous run without replacing user-managed files.
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
import re
import stat
import sys
import yaml
FILE_PATH = Path(__file__).resolve()
GENERATED_MARKER = (
"<!-- Generated by agents/scripts/install_for_copilot_cli.py. -->")
# Copilot only reads instruction files that carry this suffix.
RULE_ADAPTER_SUFFIX = ".instructions.md"
FRONTMATTER_PATTERN = re.compile(
r"\A(?P<block>---[ \t]*\r?\n(?P<yaml>.*?)\r?\n---[ \t]*)"
r"(?:\r?\n|\Z)", re.DOTALL)
COPILOT_INCOMPATIBLE_RULES = {
# Mandates `TAG=agy` and a conversation ID that this adapter cannot
# supply, which would land in uploaded CL descriptions.
"git-commit.md",
}
COPILOT_INCOMPATIBLE_SKILLS = {
# Requires client-specific approval suppression and agent lifecycle controls
# not mapped by this adapter.
"agent-evaluation-framework",
# Mandates a subagent API and model tier not mapped by this adapter.
"doc-invalidation-checker",
# Mandates internal Buganizer, session-context, and delegation APIs not
# provisioned or mapped by this adapter.
"v8-security-triaging",
# Mixes client-neutral tool setup with Jetski-specific .agents, MCP, and
# settings guidance; exclude it until split or adapted.
"v8-setup",
}
def _path_entry_exists(path: Path) -> bool:
try:
path.lstat()
except FileNotFoundError:
return False
return True
def _path_entry_is_reparse_point(path: Path) -> bool:
path_stat = path.lstat()
if stat.S_ISLNK(path_stat.st_mode):
return True
file_attributes = getattr(path_stat, "st_file_attributes", 0)
reparse_attribute = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
return bool(file_attributes & reparse_attribute)
class CopilotInstaller:
"""Installs Copilot CLI adapters for one V8 checkout."""
def __init__(self, repo_root: Path):
self.repo_root = repo_root
self.agents_dir = repo_root / "agents"
self.skills_dir = self.agents_dir / "skills"
self.rules_dir = self.agents_dir / "rules"
self.github_dir = repo_root / ".github"
self.copilot_instructions = self.github_dir / "copilot-instructions.md"
self.github_skills_dir = self.github_dir / "skills"
self.github_instructions_dir = self.github_dir / "instructions"
def _repo_path(self, path: Path) -> str:
return path.relative_to(self.repo_root).as_posix()
def _frontmatter(self, markdown_file: Path) -> tuple[dict[str, str], str]:
"""Return parsed YAML frontmatter and its original Markdown block."""
match = FRONTMATTER_PATTERN.match(markdown_file.read_text(encoding="utf-8"))
if not match:
return {}, ""
metadata = yaml.safe_load(match.group("yaml")) or {}
if not isinstance(metadata, dict):
metadata = {}
return metadata, match.group("block")
def _write_generated_file(self, path: Path, content: str) -> bool:
if path.is_symlink():
print(f"Skipping {path}: existing path is a symlink")
return False
if _path_entry_exists(path):
try:
existing = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
# Destinations under .github/ are user territory, so skip undecodable
# files instead of aborting the install part-way through.
print(f"Skipping {path}: existing file is not UTF-8 text")
return False
if GENERATED_MARKER not in existing:
print(f"Skipping {path}: existing file is not generated")
return False
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8", newline="\n")
print(f"Wrote {path}")
return True
def _rule_apply_to(self, metadata: dict[str, str]) -> str | None:
"""Return the Copilot `applyTo` glob for a rule.
`None` means Copilot has no equivalent trigger and the rule is only
advertised in the instructions.
"""
trigger = str(metadata.get("trigger", ""))
if trigger == "always_on":
# Copilot has no always-on trigger, but `**` matches every file.
return "**"
if trigger == "glob":
return str(metadata.get("globs", "")) or None
# Some rules carry the glob in the trigger field instead of `globs`.
if "*" in trigger:
return trigger
return None
def _compatible_rules(self):
"""Yield `(rule_file, metadata, apply_to)` for each compatible rule."""
if not self.rules_dir.is_dir():
return
for rule_file in sorted(self.rules_dir.glob("*.md")):
if rule_file.name in COPILOT_INCOMPATIBLE_RULES:
continue
metadata, _ = self._frontmatter(rule_file)
yield rule_file, metadata, self._rule_apply_to(metadata)
def _rule_source_for(self, adapter_name: str) -> Path:
stem = adapter_name
if stem.endswith(RULE_ADAPTER_SUFFIX):
stem = stem[:-len(RULE_ADAPTER_SUFFIX)]
return self.rules_dir / f"{stem}.md"
def _enumerate_rules(self) -> str:
"""List rules that Copilot cannot trigger on its own."""
entries = []
for rule_file, metadata, apply_to in self._compatible_rules():
if apply_to is not None:
continue
entry = f"- `{self._repo_path(rule_file)}`"
description = metadata.get("description", "")
if description:
entry += f": {description}"
entries.append(entry)
if not entries:
return ""
return ("\n## Optional rules\n\n"
"Copilot cannot trigger these rules automatically. Read one when "
"its description matches the current task:\n\n" +
"\n".join(entries) + "\n")
def _instructions(self) -> str:
return f"""{GENERATED_MARKER}
# V8 GitHub Copilot Instructions
The canonical V8 agent knowledge lives under `agents/`. Update those checked-in
files rather than this generated adapter.
## Canonical context
Rules install as Copilot instruction files under `.github/instructions/`, which
Copilot applies on its own. Load compatible V8 skills on demand through
`.github/skills/`; their canonical content and supporting resources remain in
`agents/skills/`. When a rule or skill names another agent's tool, use the
Copilot CLI equivalent.
{self._enumerate_rules()}"""
def _generated_markdown(self, path: Path) -> bool:
if not path.is_file():
return False
try:
return GENERATED_MARKER in path.read_text(encoding="utf-8")
except UnicodeDecodeError:
# A file this script cannot decode is not one it generated.
return False
def _generated_link(self, path: Path, source: Path) -> bool:
if not path.is_symlink():
return False
try:
return os.readlink(path) == os.path.relpath(source, start=path.parent)
except OSError:
return False
def _remove_generated_adapter(self, path: Path, source: Path) -> bool:
"""Remove an adapter this script generated, keeping user files intact."""
if self._generated_link(path, source):
path.unlink()
return True
if path.is_symlink() or _path_entry_is_reparse_point(path):
return False
if path.is_file():
if not self._generated_markdown(path):
return False
path.unlink()
return True
if path.is_dir() and self._generated_markdown(path / "SKILL.md"):
(path / "SKILL.md").unlink()
if not any(path.iterdir()):
path.rmdir()
return True
return False
def _skill_wrapper(self, skill_dir: Path) -> str:
skill_file = skill_dir / "SKILL.md"
metadata, frontmatter = self._frontmatter(skill_file)
name = metadata.get("name", skill_dir.name)
if not frontmatter:
frontmatter = (
"---\n"
f"name: {skill_dir.name}\n"
f"description: Adapter for the canonical V8 {skill_dir.name} skill.\n"
"---")
canonical_path = self._repo_path(skill_file)
base_path = self._repo_path(skill_dir)
return f"""{frontmatter}
{GENERATED_MARKER}
# {name}
Read and follow the canonical skill at `{canonical_path}`.
Treat `{base_path}/` as the skill's base directory when resolving relative
paths and supporting resources.
"""
def _write_skill_wrapper(self, skill_dir: Path, dest: Path) -> None:
self._write_generated_file(dest / "SKILL.md",
self._skill_wrapper(skill_dir))
def _rule_wrapper(self, rule_file: Path, apply_to: str) -> str:
"""Render a rule as an instruction file Copilot can act on.
The canonical frontmatter is dropped rather than passed through, because
its `trigger` vocabulary means nothing to Copilot.
"""
_, frontmatter = self._frontmatter(rule_file)
body = rule_file.read_text(encoding="utf-8")[len(frontmatter):]
return (f"---\napplyTo: \"{apply_to}\"\n---\n"
f"{GENERATED_MARKER}\n\n{body.lstrip()}")
def _remove_stale_adapters(self, adapters_dir: Path, current: set[str],
source_for) -> None:
if not adapters_dir.is_dir():
return
for dest in adapters_dir.iterdir():
if dest.name in current:
continue
if self._remove_generated_adapter(dest, source_for(dest.name)):
print(f"Removed stale adapter {dest}")
def _install_skill(self, skill_dir: Path, force: bool) -> None:
"""Link the skill into `.github/`, falling back to a generated wrapper."""
dest = self.github_skills_dir / skill_dir.name
if _path_entry_exists(dest):
if not force:
print(f"Skipping {dest}: already exists")
return
if not self._remove_generated_adapter(dest, skill_dir):
print(f"Skipping {dest}: existing path is not generated")
return
if _path_entry_exists(dest):
self._write_skill_wrapper(skill_dir, dest)
return
dest.parent.mkdir(parents=True, exist_ok=True)
target = Path(os.path.relpath(skill_dir, start=dest.parent))
try:
dest.symlink_to(target, target_is_directory=True)
print(f"Symlinked {dest} -> {target}")
except OSError as exc:
if _path_entry_exists(dest):
print(f"Skipping {dest}: failed to create a clean adapter: {exc}")
return
print(f"Could not symlink {dest}: {exc}; writing a wrapper instead")
self._write_skill_wrapper(skill_dir, dest)
def _install_rule(self, rule_file: Path, apply_to: str) -> None:
# A rule cannot be linked the way a skill is, because Copilot needs an
# `applyTo` header that the canonical file does not carry.
dest = self.github_instructions_dir / (rule_file.stem + RULE_ADAPTER_SUFFIX)
self._write_generated_file(dest, self._rule_wrapper(rule_file, apply_to))
def install(self, force: bool) -> int:
if not self.skills_dir.is_dir():
print(f"Missing skills directory: {self.skills_dir}", file=sys.stderr)
return 1
for destination in (self.github_dir, self.github_skills_dir,
self.github_instructions_dir):
if (_path_entry_exists(destination) and
_path_entry_is_reparse_point(destination)):
print(
f"Unsafe adapter destination: {destination} is a symlink or "
"reparse point",
file=sys.stderr)
return 1
self._write_generated_file(self.copilot_instructions, self._instructions())
self.github_skills_dir.mkdir(parents=True, exist_ok=True)
current_skills = sorted(
path for path in self.skills_dir.iterdir()
if (path.is_dir() and (path / "SKILL.md").is_file() and
path.name not in COPILOT_INCOMPATIBLE_SKILLS))
current_rules = [(rule_file, apply_to)
for rule_file, _, apply_to in self._compatible_rules()
if apply_to is not None]
# Stale adapters are dropped even without `--force`: reclaiming a path
# this script generated is not the same as overwriting a user's file, and
# leaving one behind keeps a rule active after it stops being compatible.
self._remove_stale_adapters(self.github_skills_dir,
{path.name for path in current_skills},
lambda name: self.skills_dir / name)
self._remove_stale_adapters(
self.github_instructions_dir,
{path.stem + RULE_ADAPTER_SUFFIX for path, _ in current_rules},
self._rule_source_for)
for skill_dir in current_skills:
self._install_skill(skill_dir, force)
for rule_file, apply_to in current_rules:
self._install_rule(rule_file, apply_to)
print("GitHub Copilot CLI adapter installation complete.")
print("Restart or resume your Copilot CLI session to pick up the changes.")
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--force",
action="store_true",
help="replace adapters generated by a previous run")
args = parser.parse_args(argv)
return CopilotInstaller(FILE_PATH.parents[2]).install(args.force)
if __name__ == "__main__":
sys.exit(main())