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}
354 lines
13 KiB
Python
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())
|