opencode-codeops 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +30 -0
- package/_shared/layout-convention.md +5 -0
- package/_shared/quality-profile.md +17 -4
- package/_shared/specialist-agents.md +145 -0
- package/agent-templates/domain-specialist-executor.md +20 -0
- package/agent-templates/domain-specialist-reviewer.md +17 -0
- package/package.json +1 -1
- package/schemas/codeops-config.schema.json +2 -1
- package/scripts/__pycache__/install_agents.cpython-312.pyc +0 -0
- package/scripts/codeops-migrate.sh +59 -2
- package/scripts/fixtures/catalog-executor.golden.md +59 -0
- package/scripts/install_agents.py +954 -22
- package/scripts/release.mjs +18 -1
- package/skills/analyze-project/SKILL.md +2 -1
- package/skills/exec-plan/SKILL.md +3 -1
- package/skills/exec-plan/execution-protocol.md +5 -4
- package/skills/make-plan/SKILL.md +2 -0
- package/skills/make-plan/templates.md +10 -0
- package/skills/make-requirements/SKILL.md +2 -0
- package/skills/setup-routing/SKILL.md +15 -1
- package/skills/setup-routing/routing.md +8 -0
|
@@ -3,17 +3,27 @@
|
|
|
3
3
|
install_agents.py — CodeOps for OpenCode
|
|
4
4
|
Generates OpenCode agent Markdown files from codeops/codeops.json routing config.
|
|
5
5
|
|
|
6
|
+
The generator serves two kinds of roles:
|
|
7
|
+
|
|
8
|
+
- the twelve catalog roles, generated from templates in `agent-templates/`; and
|
|
9
|
+
- project specialists, generated from a project brief in `codeops/specialists/`
|
|
10
|
+
plus one of the two generic specialist templates (`--custom <role>`).
|
|
11
|
+
|
|
6
12
|
Usage:
|
|
7
13
|
python3 install_agents.py --project <path> --roles ROLE[,ROLE...]
|
|
14
|
+
python3 install_agents.py --project <path> --custom ROLE
|
|
8
15
|
python3 install_agents.py --project <path> --check
|
|
9
16
|
python3 install_agents.py --project <path> --dry-run --roles ROLE[,ROLE...]
|
|
10
17
|
"""
|
|
11
18
|
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
12
21
|
import argparse
|
|
13
22
|
import json
|
|
14
23
|
import os
|
|
15
24
|
import re
|
|
16
25
|
import sys
|
|
26
|
+
import unicodedata
|
|
17
27
|
from pathlib import Path
|
|
18
28
|
from typing import Optional
|
|
19
29
|
|
|
@@ -52,6 +62,8 @@ TEMPLATE_PERMISSIONS: dict[str, dict[str, str]] = {
|
|
|
52
62
|
"financial-integrity-auditor": {"read": "allow", "edit": "deny", "bash": "deny"},
|
|
53
63
|
"concurrency-auditor": {"read": "allow", "edit": "deny", "bash": "deny"},
|
|
54
64
|
"semantics-reviewer": {"read": "allow", "edit": "deny", "bash": "deny"},
|
|
65
|
+
"domain-specialist-reviewer": {"read": "allow", "grep": "allow", "glob": "allow", "edit": "deny", "bash": "allow"},
|
|
66
|
+
"domain-specialist-executor": {"read": "allow", "grep": "allow", "glob": "allow", "edit": "allow", "bash": "allow"},
|
|
55
67
|
}
|
|
56
68
|
|
|
57
69
|
# effort -> temperature mapping
|
|
@@ -70,6 +82,318 @@ SANDBOX_PERMISSIONS: dict[str, dict[str, str]] = {
|
|
|
70
82
|
|
|
71
83
|
CODEOPS_MARKER = "# Generated by CodeOps install_agents.py"
|
|
72
84
|
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Custom specialist roles
|
|
87
|
+
#
|
|
88
|
+
# A specialist is defined by a project brief (`codeops/specialists/<role>.md`)
|
|
89
|
+
# and generated from one of two generic templates. Everything below is
|
|
90
|
+
# validated before any file is touched: a rejected brief leaves the project
|
|
91
|
+
# byte-for-byte unchanged.
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
# Brief `kind` -> generic template; the only two supported specialist shapes.
|
|
95
|
+
CUSTOM_TEMPLATES: dict[str, str] = {
|
|
96
|
+
"reviewer": "domain-specialist-reviewer",
|
|
97
|
+
"executor": "domain-specialist-executor",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# OpenCode's built-in agent names; a specialist may never shadow one.
|
|
101
|
+
BUILTIN_AGENT_NAMES = frozenset(
|
|
102
|
+
{"plan", "build", "general", "explore", "scout", "compaction", "title", "summary"}
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Windows device names; creating such a file breaks checkouts on that platform.
|
|
106
|
+
DOS_DEVICE_NAMES = frozenset(
|
|
107
|
+
{"con", "nul", "aux", "prn"}
|
|
108
|
+
| {f"com{number}" for number in range(1, 10)}
|
|
109
|
+
| {f"lpt{number}" for number in range(1, 10)}
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Role slugs are short, portable, and lowercase so they map safely to both a
|
|
113
|
+
# filename and an OpenCode agent name.
|
|
114
|
+
ROLE_PATTERN = re.compile(r"^[a-z][a-z0-9-]{1,40}$")
|
|
115
|
+
|
|
116
|
+
# Provider passthrough values for the generated `reasoningEffort`; a routing
|
|
117
|
+
# override outside this list is rejected rather than forwarded.
|
|
118
|
+
REASONING_VALUES = ("none", "minimal", "low", "medium", "high", "xhigh", "max")
|
|
119
|
+
|
|
120
|
+
# Maximum length of each single-line brief field after sanitization.
|
|
121
|
+
BRIEF_TEXT_LIMITS: dict[str, int] = {
|
|
122
|
+
"description": 200,
|
|
123
|
+
"capability": 200,
|
|
124
|
+
"scope": 200,
|
|
125
|
+
"evidence": 500,
|
|
126
|
+
"required-for": 200,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
# The brief Markdown body may not exceed this many bytes.
|
|
130
|
+
MAX_BRIEF_BODY_BYTES = 16384
|
|
131
|
+
|
|
132
|
+
# Managed AGENTS.md index block. The markers are the whole contract: the sync
|
|
133
|
+
# replaces only what sits between them and removes the block when no specialist
|
|
134
|
+
# briefs remain.
|
|
135
|
+
AGENTS_START = "<!-- CODEOPS-SPECIALISTS:START -->"
|
|
136
|
+
AGENTS_END = "<!-- CODEOPS-SPECIALISTS:END -->"
|
|
137
|
+
|
|
138
|
+
# Soft budget: beyond this many entries the block ends with an overflow
|
|
139
|
+
# pointer instead of growing without bound.
|
|
140
|
+
AGENTS_ENTRY_BUDGET = 15
|
|
141
|
+
|
|
142
|
+
# Generated specialist agents carry this template-name prefix in their
|
|
143
|
+
# ownership header; removal refuses anything else.
|
|
144
|
+
CUSTOM_TEMPLATE_PREFIX = "domain-specialist-"
|
|
145
|
+
|
|
146
|
+
# Every frontmatter key a brief may carry; anything else is an error.
|
|
147
|
+
BRIEF_KNOWN_KEYS = (
|
|
148
|
+
"schema",
|
|
149
|
+
"role",
|
|
150
|
+
"kind",
|
|
151
|
+
"description",
|
|
152
|
+
"capability",
|
|
153
|
+
"scope",
|
|
154
|
+
"evidence",
|
|
155
|
+
"effort",
|
|
156
|
+
"reasoning",
|
|
157
|
+
"hidden",
|
|
158
|
+
"required-for",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Frontmatter keys a brief must define.
|
|
162
|
+
BRIEF_REQUIRED_KEYS = ("role", "kind", "description")
|
|
163
|
+
|
|
164
|
+
# Invisible Unicode categories removed from prompt-bound text. Hostile or
|
|
165
|
+
# accidental control characters must not reach an agent file or AGENTS.md.
|
|
166
|
+
STRIPPED_CATEGORIES = frozenset({"Cc", "Cf"})
|
|
167
|
+
|
|
168
|
+
# Zero-width joiner: category `Cf`, but stripping it breaks emoji sequences,
|
|
169
|
+
# so it is the one invisible character that survives sanitization.
|
|
170
|
+
PRESERVED_INVISIBLE = "\u200d"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class BriefError(ValueError):
|
|
174
|
+
"""Raised when a specialist brief is missing, malformed, or unsafe.
|
|
175
|
+
|
|
176
|
+
The message is user-facing: it names the field, line, or rule that failed
|
|
177
|
+
so the author can fix the brief without reading the installer source.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def is_visible(character: str) -> bool:
|
|
182
|
+
"""Return True when a character may stay in prompt-bound text.
|
|
183
|
+
|
|
184
|
+
Control (`Cc`) and format (`Cf`) characters are invisible or reorder text,
|
|
185
|
+
so they are removed — except the zero-width joiner, which is part of
|
|
186
|
+
legitimate emoji sequences.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
character: A single character.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
True when the character is safe to keep.
|
|
193
|
+
"""
|
|
194
|
+
if character == PRESERVED_INVISIBLE:
|
|
195
|
+
return True
|
|
196
|
+
return unicodedata.category(character) not in STRIPPED_CATEGORIES
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def sanitize_prompt_text(value: str, limit: int) -> str:
|
|
200
|
+
"""Sanitize one single-line brief value before it reaches a prompt.
|
|
201
|
+
|
|
202
|
+
The result is stable under repeated sanitization (a fixed point): invisible
|
|
203
|
+
characters are removed, then the whitespace-collapse and HTML comment
|
|
204
|
+
marker removal pass repeats until the value stops changing, and finally the
|
|
205
|
+
value is trimmed and length-capped. Removing markers can stitch a new
|
|
206
|
+
marker together (and can leave double spaces behind), which is why the
|
|
207
|
+
whole pass repeats until nothing changes.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
value: Raw field value from the brief.
|
|
211
|
+
limit: Maximum length of the sanitized value.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
The sanitized, length-capped value.
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
BriefError: If any marker sequence is still present after the fixed
|
|
218
|
+
point is reached (defensive; a correct loop cannot leave one).
|
|
219
|
+
"""
|
|
220
|
+
cleaned = "".join(character for character in value if is_visible(character))
|
|
221
|
+
previous = None
|
|
222
|
+
while previous != cleaned:
|
|
223
|
+
previous = cleaned
|
|
224
|
+
cleaned = re.sub(r"\s+", " ", cleaned)
|
|
225
|
+
cleaned = cleaned.replace("<!--", "").replace("-->", "")
|
|
226
|
+
if "<!--" in cleaned or "-->" in cleaned:
|
|
227
|
+
raise BriefError("value still contains a comment marker after sanitization")
|
|
228
|
+
return cleaned.strip()[:limit]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def sanitize_brief_body(body: str) -> str:
|
|
232
|
+
"""Sanitize a brief body while preserving its Markdown structure.
|
|
233
|
+
|
|
234
|
+
Invisible characters are removed (newlines and tabs survive), so headings,
|
|
235
|
+
lists, and code blocks keep their shape. The body is otherwise emitted
|
|
236
|
+
as authored.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
body: Raw Markdown body from the brief.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
The body with invisible characters removed.
|
|
243
|
+
"""
|
|
244
|
+
return "".join(
|
|
245
|
+
character
|
|
246
|
+
for character in body
|
|
247
|
+
if character in "\n\t" or is_visible(character)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def validate_role_name(role: str) -> None:
|
|
252
|
+
"""Validate a specialist role slug against every naming rule.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
role: Proposed role name.
|
|
256
|
+
|
|
257
|
+
Raises:
|
|
258
|
+
BriefError: If the name is malformed, reserved, or platform-hostile.
|
|
259
|
+
"""
|
|
260
|
+
if not ROLE_PATTERN.fullmatch(role):
|
|
261
|
+
raise BriefError(
|
|
262
|
+
f"invalid role name {role!r}: must match {ROLE_PATTERN.pattern}"
|
|
263
|
+
)
|
|
264
|
+
if role in ROLE_TO_TEMPLATE:
|
|
265
|
+
raise BriefError(f"role {role!r} is a CodeOps catalog role and cannot be a specialist")
|
|
266
|
+
if role in BUILTIN_AGENT_NAMES:
|
|
267
|
+
raise BriefError(f"role {role!r} is an OpenCode built-in agent name and cannot be a specialist")
|
|
268
|
+
if role in DOS_DEVICE_NAMES:
|
|
269
|
+
raise BriefError(f"role {role!r} is a reserved device name and cannot be a specialist")
|
|
270
|
+
if role.endswith(".") or role.endswith(" "):
|
|
271
|
+
raise BriefError(f"role {role!r} may not end with a dot or a space")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def parse_brief(path: Path, role: str) -> dict:
|
|
275
|
+
"""Parse and validate a specialist brief.
|
|
276
|
+
|
|
277
|
+
The brief format is intentionally small: a `---` frontmatter block of flat
|
|
278
|
+
`key: value` lines, followed by a Markdown body. Unknown keys, duplicate
|
|
279
|
+
keys, missing required fields, and values outside their documented ranges
|
|
280
|
+
are all errors — the caller reports them and writes nothing.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
path: Path to the brief file.
|
|
284
|
+
role: Role expected from the filename (without `.md`).
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
A dictionary of validated fields plus `body` (sanitized Markdown) and
|
|
288
|
+
`body_bytes` (the raw body size in bytes, returned for callers and
|
|
289
|
+
error reporting).
|
|
290
|
+
|
|
291
|
+
Raises:
|
|
292
|
+
BriefError: If the briefing is unreadable or violates any rule.
|
|
293
|
+
"""
|
|
294
|
+
try:
|
|
295
|
+
size = path.stat().st_size
|
|
296
|
+
except OSError as exc:
|
|
297
|
+
raise BriefError(f"cannot read brief {path}: {exc}") from exc
|
|
298
|
+
if size > MAX_BRIEF_BODY_BYTES + 65536:
|
|
299
|
+
raise BriefError(
|
|
300
|
+
f"brief file is too large: {size} bytes (the body maximum is "
|
|
301
|
+
f"{MAX_BRIEF_BODY_BYTES} bytes)"
|
|
302
|
+
)
|
|
303
|
+
try:
|
|
304
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
305
|
+
except UnicodeDecodeError as exc:
|
|
306
|
+
raise BriefError(f"brief is not valid UTF-8: {path}") from exc
|
|
307
|
+
except OSError as exc:
|
|
308
|
+
raise BriefError(f"cannot read brief {path}: {exc}") from exc
|
|
309
|
+
|
|
310
|
+
lines = text.split("\n")
|
|
311
|
+
if not lines or lines[0].strip() != "---":
|
|
312
|
+
raise BriefError("brief must start with a --- frontmatter line")
|
|
313
|
+
closing = None
|
|
314
|
+
for index in range(1, len(lines)):
|
|
315
|
+
if lines[index].strip() == "---":
|
|
316
|
+
closing = index
|
|
317
|
+
break
|
|
318
|
+
if closing is None:
|
|
319
|
+
raise BriefError("brief frontmatter is not closed with a --- line")
|
|
320
|
+
|
|
321
|
+
fields: dict[str, str] = {}
|
|
322
|
+
for index in range(1, closing):
|
|
323
|
+
line = lines[index]
|
|
324
|
+
if not line.strip():
|
|
325
|
+
continue
|
|
326
|
+
key, separator, value = line.partition(":")
|
|
327
|
+
key = key.strip()
|
|
328
|
+
if not separator or not re.fullmatch(r"[A-Za-z][A-Za-z0-9-]*", key):
|
|
329
|
+
raise BriefError(f"malformed frontmatter line {index + 1}")
|
|
330
|
+
if key not in BRIEF_KNOWN_KEYS:
|
|
331
|
+
raise BriefError(
|
|
332
|
+
f"unknown frontmatter key {key!r} at line {index + 1}; "
|
|
333
|
+
f"known keys: {', '.join(BRIEF_KNOWN_KEYS)}"
|
|
334
|
+
)
|
|
335
|
+
if key in fields:
|
|
336
|
+
raise BriefError(f"duplicate frontmatter key {key!r} at line {index + 1}")
|
|
337
|
+
fields[key] = value.strip()
|
|
338
|
+
|
|
339
|
+
for key in BRIEF_REQUIRED_KEYS:
|
|
340
|
+
if not fields.get(key):
|
|
341
|
+
raise BriefError(f"missing required frontmatter field {key!r}")
|
|
342
|
+
|
|
343
|
+
schema = fields.get("schema", "1")
|
|
344
|
+
if schema != "1":
|
|
345
|
+
raise BriefError(f"unsupported brief schema {schema!r}; this installer supports schema 1")
|
|
346
|
+
|
|
347
|
+
if fields["role"] != role:
|
|
348
|
+
raise BriefError(
|
|
349
|
+
f"frontmatter role {fields['role']!r} does not match the filename {role!r}"
|
|
350
|
+
)
|
|
351
|
+
validate_role_name(role)
|
|
352
|
+
|
|
353
|
+
kind = fields["kind"]
|
|
354
|
+
if kind not in CUSTOM_TEMPLATES:
|
|
355
|
+
raise BriefError(
|
|
356
|
+
f"unsupported kind {kind!r}; supported kinds: {', '.join(sorted(CUSTOM_TEMPLATES))}"
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
hidden = fields.get("hidden", "false")
|
|
360
|
+
if hidden not in ("true", "false"):
|
|
361
|
+
raise BriefError(f"hidden must be 'true' or 'false', got {hidden!r}")
|
|
362
|
+
|
|
363
|
+
effort = fields.get("effort")
|
|
364
|
+
if effort is not None and effort not in EFFORT_TO_TEMPERATURE:
|
|
365
|
+
raise BriefError(
|
|
366
|
+
f"unsupported effort {effort!r}; supported values: {', '.join(sorted(EFFORT_TO_TEMPERATURE))}"
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
reasoning = fields.get("reasoning")
|
|
370
|
+
if reasoning is not None and reasoning not in REASONING_VALUES:
|
|
371
|
+
raise BriefError(
|
|
372
|
+
f"unsupported reasoning {reasoning!r}; supported values: {', '.join(REASONING_VALUES)}"
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
validated: dict[str, object] = {
|
|
376
|
+
"kind": kind,
|
|
377
|
+
"hidden": hidden == "true",
|
|
378
|
+
"effort": effort,
|
|
379
|
+
"reasoning": reasoning,
|
|
380
|
+
}
|
|
381
|
+
for key, limit in BRIEF_TEXT_LIMITS.items():
|
|
382
|
+
raw = fields.get(key)
|
|
383
|
+
validated[key] = sanitize_prompt_text(raw, limit) if raw is not None else None
|
|
384
|
+
if not validated["description"]:
|
|
385
|
+
raise BriefError("description is empty after sanitization")
|
|
386
|
+
|
|
387
|
+
body = "\n".join(lines[closing + 1:]).lstrip("\n")
|
|
388
|
+
body_bytes = len(body.encode("utf-8"))
|
|
389
|
+
if body_bytes > MAX_BRIEF_BODY_BYTES:
|
|
390
|
+
raise BriefError(
|
|
391
|
+
f"brief body is {body_bytes} bytes; the maximum is {MAX_BRIEF_BODY_BYTES} bytes"
|
|
392
|
+
)
|
|
393
|
+
validated["body"] = sanitize_brief_body(body)
|
|
394
|
+
validated["body_bytes"] = body_bytes
|
|
395
|
+
return validated
|
|
396
|
+
|
|
73
397
|
|
|
74
398
|
def find_plugin_root() -> Optional[Path]:
|
|
75
399
|
"""Locate the opencode-codeops package root via CODEOPS_PLUGIN_ROOT or this script's location."""
|
|
@@ -180,20 +504,637 @@ def generate_agent_file(
|
|
|
180
504
|
|
|
181
505
|
|
|
182
506
|
def is_codeops_generated(path: Path) -> bool:
|
|
183
|
-
"""Return True if the file was generated by this script (has the CodeOps marker).
|
|
184
|
-
|
|
507
|
+
"""Return True if the file was generated by this script (has the CodeOps marker).
|
|
508
|
+
|
|
509
|
+
Unreadable or non-UTF-8 files are treated as not generated, so a corrupt
|
|
510
|
+
file is reported as a state instead of crashing the caller.
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
path: Candidate generated agent file.
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
True when the first line carries the CodeOps ownership marker.
|
|
517
|
+
"""
|
|
518
|
+
if not path.is_file():
|
|
519
|
+
return False
|
|
520
|
+
try:
|
|
521
|
+
first_line = path.read_text(encoding="utf-8").split("\n", 1)[0]
|
|
522
|
+
except (OSError, UnicodeDecodeError):
|
|
185
523
|
return False
|
|
186
|
-
first_line = path.read_text(encoding="utf-8").split("\n", 1)[0]
|
|
187
524
|
return first_line.strip() == CODEOPS_MARKER
|
|
188
525
|
|
|
189
526
|
|
|
527
|
+
def generate_custom_agent(plugin_root: Path, role: str, brief: dict, role_config: dict) -> str:
|
|
528
|
+
"""Generate the full content of a specialist agent file.
|
|
529
|
+
|
|
530
|
+
The generated file embeds two things: the generic template contract chosen
|
|
531
|
+
by the brief's `kind`, and the project brief body. Text that can contain
|
|
532
|
+
arbitrary characters (the description and a routing model pin) is written
|
|
533
|
+
as a JSON-escaped double-quoted YAML scalar, so YAML indicator characters
|
|
534
|
+
cannot break parsing or inject frontmatter keys. Enum-bound values
|
|
535
|
+
(`reasoning`, `effort`) are validated first and then emitted plain.
|
|
536
|
+
|
|
537
|
+
Resolution order (most specific wins):
|
|
538
|
+
- reasoning: routing policy -> brief -> `max`
|
|
539
|
+
- effort/temperature: routing policy -> brief -> `high`
|
|
540
|
+
- model: routing policy only (no hardcoded model)
|
|
541
|
+
- permissions: template default, tightened by a routing sandbox override,
|
|
542
|
+
with reviewer `edit` forced to `deny`.
|
|
543
|
+
|
|
544
|
+
Args:
|
|
545
|
+
plugin_root: Package root containing `agent-templates/`.
|
|
546
|
+
role: Validated specialist role name.
|
|
547
|
+
brief: Parsed brief returned by `parse_brief`.
|
|
548
|
+
role_config: Routing policy for this role (may be empty).
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
The complete agent Markdown file content.
|
|
552
|
+
"""
|
|
553
|
+
template_name = CUSTOM_TEMPLATES[brief["kind"]]
|
|
554
|
+
|
|
555
|
+
effort = role_config.get("effort") or brief.get("effort") or "high"
|
|
556
|
+
if effort not in EFFORT_TO_TEMPERATURE:
|
|
557
|
+
raise BriefError(
|
|
558
|
+
f"unsupported routing effort {effort!r}; supported values: "
|
|
559
|
+
f"{', '.join(sorted(EFFORT_TO_TEMPERATURE))}"
|
|
560
|
+
)
|
|
561
|
+
temperature = EFFORT_TO_TEMPERATURE[effort]
|
|
562
|
+
|
|
563
|
+
reasoning = role_config.get("reasoning") or brief.get("reasoning") or "max"
|
|
564
|
+
if reasoning not in REASONING_VALUES:
|
|
565
|
+
raise BriefError(
|
|
566
|
+
f"unsupported routing reasoning {reasoning!r}; supported values: "
|
|
567
|
+
f"{', '.join(REASONING_VALUES)}"
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
model = role_config.get("model") or None
|
|
571
|
+
|
|
572
|
+
permissions = dict(TEMPLATE_PERMISSIONS[template_name])
|
|
573
|
+
sandbox = role_config.get("sandbox")
|
|
574
|
+
if sandbox:
|
|
575
|
+
if sandbox not in SANDBOX_PERMISSIONS:
|
|
576
|
+
raise BriefError(
|
|
577
|
+
f"unsupported routing sandbox {sandbox!r}; supported values: "
|
|
578
|
+
f"{', '.join(sorted(SANDBOX_PERMISSIONS))}"
|
|
579
|
+
)
|
|
580
|
+
permissions.update(SANDBOX_PERMISSIONS[sandbox])
|
|
581
|
+
if brief["kind"] == "reviewer":
|
|
582
|
+
permissions["edit"] = "deny"
|
|
583
|
+
|
|
584
|
+
lines = [
|
|
585
|
+
CODEOPS_MARKER,
|
|
586
|
+
f"# Role: {role} | Template: {template_name}",
|
|
587
|
+
"# Do not edit this file manually — regenerate with: install_agents.py",
|
|
588
|
+
"---",
|
|
589
|
+
f"description: {json.dumps(brief['description'])}",
|
|
590
|
+
"mode: subagent",
|
|
591
|
+
]
|
|
592
|
+
if model:
|
|
593
|
+
lines.append(f"model: {json.dumps(model)}")
|
|
594
|
+
lines.append(f"temperature: {temperature}")
|
|
595
|
+
lines.append(f"hidden: {'true' if brief['hidden'] else 'false'}")
|
|
596
|
+
lines.append(f"reasoningEffort: {reasoning}")
|
|
597
|
+
lines.append("permission:")
|
|
598
|
+
for key, value in permissions.items():
|
|
599
|
+
lines.append(f" {key}: {value}")
|
|
600
|
+
lines.append("---")
|
|
601
|
+
frontmatter = "\n".join(lines) + "\n"
|
|
602
|
+
|
|
603
|
+
contract = load_template_body(plugin_root, template_name).strip()
|
|
604
|
+
body = str(brief["body"]).strip("\n")
|
|
605
|
+
return f"{frontmatter}\n{contract}\n\n---\n\n{body}\n"
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def write_generated_file(path: Path, content: str) -> None:
|
|
609
|
+
"""Write generated content with a hardened open.
|
|
610
|
+
|
|
611
|
+
The target is opened with `O_NOFOLLOW` where the platform supports it, so a
|
|
612
|
+
symlink swapped in after the caller's checks cannot redirect the write.
|
|
613
|
+
This is a plain write (truncate and write), not an atomic replace.
|
|
614
|
+
|
|
615
|
+
Args:
|
|
616
|
+
path: Destination file.
|
|
617
|
+
content: Full file content.
|
|
618
|
+
|
|
619
|
+
Raises:
|
|
620
|
+
BriefError: If the file cannot be opened or written.
|
|
621
|
+
"""
|
|
622
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
|
|
623
|
+
try:
|
|
624
|
+
descriptor = os.open(path, flags, 0o644)
|
|
625
|
+
except OSError as exc:
|
|
626
|
+
raise BriefError(f"cannot write {path}: {exc}") from exc
|
|
627
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
628
|
+
handle.write(content)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def dominant_newline(content: str) -> str:
|
|
632
|
+
"""Return the dominant line ending of a text file's content.
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
content: File content read without newline translation.
|
|
636
|
+
|
|
637
|
+
Returns:
|
|
638
|
+
`"\\r\\n"` when CRLF pairs outnumber lone LF characters, else `"\\n"`.
|
|
639
|
+
"""
|
|
640
|
+
crlf = content.count("\r\n")
|
|
641
|
+
lone_lf = content.count("\n") - crlf
|
|
642
|
+
return "\r\n" if crlf > lone_lf else "\n"
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def read_with_newlines(path: Path) -> str:
|
|
646
|
+
"""Read a text file without newline translation.
|
|
647
|
+
|
|
648
|
+
Python's default text mode rewrites CRLF to LF on read; the AGENTS.md sync
|
|
649
|
+
must see the file exactly as it is so it can preserve the dominant line
|
|
650
|
+
ending. This reads with `newline=""` so no translation happens.
|
|
651
|
+
|
|
652
|
+
Args:
|
|
653
|
+
path: File to read.
|
|
654
|
+
|
|
655
|
+
Returns:
|
|
656
|
+
The file content with its original line endings.
|
|
657
|
+
|
|
658
|
+
Raises:
|
|
659
|
+
OSError: If the file cannot be read.
|
|
660
|
+
UnicodeDecodeError: If the file is not valid UTF-8.
|
|
661
|
+
"""
|
|
662
|
+
with path.open("r", encoding="utf-8", newline="") as handle:
|
|
663
|
+
return handle.read()
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def collect_briefs(project: Path) -> tuple[dict, list]:
|
|
667
|
+
"""Collect and validate every specialist brief in a project.
|
|
668
|
+
|
|
669
|
+
Args:
|
|
670
|
+
project: Resolved project root.
|
|
671
|
+
|
|
672
|
+
Returns:
|
|
673
|
+
A tuple `(briefs, errors)`. `briefs` maps role -> parsed brief; each
|
|
674
|
+
entry in `errors` is a ready-to-print `INVALID: <role> — <reason>`
|
|
675
|
+
line. A symlinked specialists directory is reported as one error and
|
|
676
|
+
yields no briefs.
|
|
677
|
+
"""
|
|
678
|
+
brief_dir = project / "codeops" / "specialists"
|
|
679
|
+
briefs: dict[str, dict] = {}
|
|
680
|
+
errors: list[str] = []
|
|
681
|
+
if brief_dir.is_symlink():
|
|
682
|
+
return briefs, ["INVALID: specialists — refusing to read through a symlinked directory"]
|
|
683
|
+
if not brief_dir.is_dir():
|
|
684
|
+
return briefs, errors
|
|
685
|
+
for path in sorted(brief_dir.glob("*.md")):
|
|
686
|
+
role = path.stem
|
|
687
|
+
if path.is_symlink():
|
|
688
|
+
errors.append(f"INVALID: {role} — brief is a symlink")
|
|
689
|
+
continue
|
|
690
|
+
try:
|
|
691
|
+
briefs[role] = parse_brief(path, role)
|
|
692
|
+
except BriefError as exc:
|
|
693
|
+
errors.append(f"INVALID: {role} — {exc}")
|
|
694
|
+
return briefs, errors
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def generated_custom_template(path: Path) -> Optional[str]:
|
|
698
|
+
"""Return the template name recorded in a generated agent's header.
|
|
699
|
+
|
|
700
|
+
Args:
|
|
701
|
+
path: Candidate generated agent file.
|
|
702
|
+
|
|
703
|
+
Returns:
|
|
704
|
+
The template name (for example `domain-specialist-reviewer`) when the
|
|
705
|
+
file carries the CodeOps ownership marker and role header, otherwise
|
|
706
|
+
`None`.
|
|
707
|
+
"""
|
|
708
|
+
if not is_codeops_generated(path):
|
|
709
|
+
return None
|
|
710
|
+
try:
|
|
711
|
+
lines = path.read_text(encoding="utf-8").split("\n", 3)[:3]
|
|
712
|
+
except (OSError, UnicodeDecodeError):
|
|
713
|
+
return None
|
|
714
|
+
for line in lines:
|
|
715
|
+
match = re.fullmatch(r"# Role: .+ \| Template: (.+)", line)
|
|
716
|
+
if match:
|
|
717
|
+
return match.group(1)
|
|
718
|
+
return None
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def render_agents_block(briefs: dict, newline: str) -> str:
|
|
722
|
+
"""Render the managed AGENTS.md block for a set of briefs.
|
|
723
|
+
|
|
724
|
+
Args:
|
|
725
|
+
briefs: Role -> parsed brief mapping.
|
|
726
|
+
newline: Line ending to use (`\\n` or `\\r\\n`).
|
|
727
|
+
|
|
728
|
+
Returns:
|
|
729
|
+
The block text without a trailing newline.
|
|
730
|
+
"""
|
|
731
|
+
lines = [
|
|
732
|
+
AGENTS_START,
|
|
733
|
+
"Specialist agents (routing: `codeops/codeops.json`; briefs: `codeops/specialists/`):",
|
|
734
|
+
]
|
|
735
|
+
roles = sorted(briefs)
|
|
736
|
+
for role in roles[:AGENTS_ENTRY_BUDGET]:
|
|
737
|
+
entry = f'- `{role}` — "{briefs[role]["description"]}"'
|
|
738
|
+
required_for = briefs[role].get("required-for")
|
|
739
|
+
if required_for:
|
|
740
|
+
entry += f" (Required for: {required_for})"
|
|
741
|
+
lines.append(entry)
|
|
742
|
+
if len(roles) > AGENTS_ENTRY_BUDGET:
|
|
743
|
+
lines.append(f"- …and {len(roles) - AGENTS_ENTRY_BUDGET} more; see codeops/specialists/")
|
|
744
|
+
lines.append(AGENTS_END)
|
|
745
|
+
return newline.join(lines)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def classify_agents_block(content: str) -> tuple[str, int, int]:
|
|
749
|
+
"""Classify the marker layout of an AGENTS.md file.
|
|
750
|
+
|
|
751
|
+
Args:
|
|
752
|
+
content: Current AGENTS.md content (empty string when absent).
|
|
753
|
+
|
|
754
|
+
Returns:
|
|
755
|
+
`("absent", -1, -1)` when no markers exist, or
|
|
756
|
+
`("present", start, end)` when exactly one ordered pair exists.
|
|
757
|
+
|
|
758
|
+
Raises:
|
|
759
|
+
BriefError: For one marker, duplicate markers, or reversed order.
|
|
760
|
+
"""
|
|
761
|
+
starts = content.count(AGENTS_START)
|
|
762
|
+
ends = content.count(AGENTS_END)
|
|
763
|
+
if starts == 0 and ends == 0:
|
|
764
|
+
return "absent", -1, -1
|
|
765
|
+
if starts == 1 and ends == 1:
|
|
766
|
+
start = content.find(AGENTS_START)
|
|
767
|
+
end = content.find(AGENTS_END)
|
|
768
|
+
if start < end:
|
|
769
|
+
return "present", start, end
|
|
770
|
+
raise BriefError("AGENTS.md contains malformed specialist markers")
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def compute_agents_md(project: Path, briefs: dict) -> tuple:
|
|
774
|
+
"""Compute the post-change AGENTS.md content for a set of briefs.
|
|
775
|
+
|
|
776
|
+
Args:
|
|
777
|
+
project: Resolved project root.
|
|
778
|
+
briefs: Briefs that should be listed after the operation.
|
|
779
|
+
|
|
780
|
+
Returns:
|
|
781
|
+
A tuple `(path, new_content, error)`. `new_content` is `None` when no
|
|
782
|
+
change is needed; `error` is a message when the current file cannot be
|
|
783
|
+
updated safely.
|
|
784
|
+
|
|
785
|
+
Raises:
|
|
786
|
+
BriefError: If the file is unreadable or not a regular file.
|
|
787
|
+
"""
|
|
788
|
+
path = project / "AGENTS.md"
|
|
789
|
+
if path.is_symlink():
|
|
790
|
+
return path, None, "refusing to write through a symlinked AGENTS.md"
|
|
791
|
+
exists = path.exists()
|
|
792
|
+
if exists and not path.is_file():
|
|
793
|
+
return path, None, "AGENTS.md is not a regular file"
|
|
794
|
+
if not exists:
|
|
795
|
+
content = ""
|
|
796
|
+
else:
|
|
797
|
+
try:
|
|
798
|
+
content = read_with_newlines(path)
|
|
799
|
+
except UnicodeDecodeError:
|
|
800
|
+
return path, None, "AGENTS.md is not valid UTF-8"
|
|
801
|
+
except OSError as exc:
|
|
802
|
+
return path, None, f"cannot read AGENTS.md: {exc}"
|
|
803
|
+
|
|
804
|
+
newline = dominant_newline(content)
|
|
805
|
+
try:
|
|
806
|
+
state, start, end = classify_agents_block(content)
|
|
807
|
+
except BriefError as exc:
|
|
808
|
+
return path, None, str(exc)
|
|
809
|
+
block = render_agents_block(briefs, newline)
|
|
810
|
+
|
|
811
|
+
if state == "present":
|
|
812
|
+
if not briefs:
|
|
813
|
+
remove_start = start
|
|
814
|
+
remove_end = end + len(AGENTS_END)
|
|
815
|
+
if content[:remove_start].endswith(newline + newline):
|
|
816
|
+
remove_start -= len(newline)
|
|
817
|
+
if content[remove_end:].startswith(newline):
|
|
818
|
+
remove_end += len(newline)
|
|
819
|
+
return path, content[:remove_start] + content[remove_end:], None
|
|
820
|
+
return path, content[:start] + block + content[end + len(AGENTS_END):], None
|
|
821
|
+
|
|
822
|
+
if not briefs:
|
|
823
|
+
return path, None, None
|
|
824
|
+
if not exists or content == "":
|
|
825
|
+
return path, block + newline, None
|
|
826
|
+
base = content
|
|
827
|
+
if not base.endswith(("\n", "\r")):
|
|
828
|
+
base += newline
|
|
829
|
+
separator = "" if base.endswith(newline + newline) else newline
|
|
830
|
+
return path, base + separator + block + newline, None
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def run_sync_agents_md(project: Path, dry_run: bool) -> int:
|
|
834
|
+
"""Render and apply the managed specialist block in AGENTS.md.
|
|
835
|
+
|
|
836
|
+
Args:
|
|
837
|
+
project: Resolved project root.
|
|
838
|
+
dry_run: When True, report the intended change and write nothing.
|
|
839
|
+
|
|
840
|
+
Returns:
|
|
841
|
+
Process exit code (0 on success, 1 when a brief or the file is invalid).
|
|
842
|
+
"""
|
|
843
|
+
try:
|
|
844
|
+
briefs, errors = collect_briefs(project)
|
|
845
|
+
if errors:
|
|
846
|
+
for error in errors:
|
|
847
|
+
print(error)
|
|
848
|
+
return 1
|
|
849
|
+
path, new_content, error = compute_agents_md(project, briefs)
|
|
850
|
+
if error:
|
|
851
|
+
raise BriefError(error)
|
|
852
|
+
if new_content is None:
|
|
853
|
+
print("AGENTS.md: no specialist block to change")
|
|
854
|
+
return 0
|
|
855
|
+
if dry_run:
|
|
856
|
+
print(f" [dry-run] would update: {path} ({len(briefs)} specialist(s))")
|
|
857
|
+
return 0
|
|
858
|
+
if path.exists() and not os.access(path, os.W_OK):
|
|
859
|
+
raise BriefError(f"AGENTS.md is not writable: {path}")
|
|
860
|
+
write_generated_file(path, new_content)
|
|
861
|
+
print(f" updated: {path.name} ({len(briefs)} specialist(s))")
|
|
862
|
+
return 0
|
|
863
|
+
except BriefError as exc:
|
|
864
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
865
|
+
return 1
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def run_check(project: Path, plugin_root: Path, roles: list, routing_roles: dict) -> int:
|
|
869
|
+
"""Report catalog, custom, orphan, and AGENTS.md states.
|
|
870
|
+
|
|
871
|
+
Args:
|
|
872
|
+
project: Resolved project root.
|
|
873
|
+
plugin_root: Package root containing `agent-templates/`.
|
|
874
|
+
roles: Catalog roles requested for the default check.
|
|
875
|
+
routing_roles: Parsed `routing.roles` policy from `codeops.json`.
|
|
876
|
+
|
|
877
|
+
Returns:
|
|
878
|
+
Process exit code: 1 when any issue is reported, otherwise 0 with a
|
|
879
|
+
`OK: N default, M custom` summary.
|
|
880
|
+
"""
|
|
881
|
+
agents_dir = project / ".opencode" / "agents"
|
|
882
|
+
issues: list[str] = []
|
|
883
|
+
for role in roles:
|
|
884
|
+
out_path = agents_dir / f"{role}.md"
|
|
885
|
+
if not out_path.exists():
|
|
886
|
+
issues.append(f"MISSING: {out_path}")
|
|
887
|
+
elif not is_codeops_generated(out_path):
|
|
888
|
+
issues.append(f"HAND-AUTHORED (skip): {out_path}")
|
|
889
|
+
|
|
890
|
+
briefs, errors = collect_briefs(project)
|
|
891
|
+
issues.extend(errors)
|
|
892
|
+
for role, brief in sorted(briefs.items()):
|
|
893
|
+
out_path = agents_dir / f"{role}.md"
|
|
894
|
+
if out_path.is_symlink() or (out_path.exists() and not out_path.is_file()):
|
|
895
|
+
issues.append(f"HAND-AUTHORED: {role} (not managed)")
|
|
896
|
+
continue
|
|
897
|
+
if not out_path.exists():
|
|
898
|
+
issues.append(f"MISSING: {role}")
|
|
899
|
+
continue
|
|
900
|
+
if not is_codeops_generated(out_path):
|
|
901
|
+
issues.append(f"HAND-AUTHORED: {role} (not managed)")
|
|
902
|
+
continue
|
|
903
|
+
try:
|
|
904
|
+
expected = generate_custom_agent(plugin_root, role, brief, routing_roles.get(role, {}))
|
|
905
|
+
except BriefError as exc:
|
|
906
|
+
issues.append(f"INVALID: {role} — {exc}")
|
|
907
|
+
continue
|
|
908
|
+
try:
|
|
909
|
+
current = out_path.read_text(encoding="utf-8")
|
|
910
|
+
except (OSError, UnicodeDecodeError):
|
|
911
|
+
issues.append(f"STALE: {role}")
|
|
912
|
+
continue
|
|
913
|
+
if current != expected:
|
|
914
|
+
issues.append(f"STALE: {role}")
|
|
915
|
+
|
|
916
|
+
if agents_dir.is_dir():
|
|
917
|
+
for file in sorted(agents_dir.glob("*.md")):
|
|
918
|
+
template = generated_custom_template(file)
|
|
919
|
+
if not template or not template.startswith(CUSTOM_TEMPLATE_PREFIX):
|
|
920
|
+
continue
|
|
921
|
+
if file.stem in briefs:
|
|
922
|
+
continue
|
|
923
|
+
if (project / "codeops" / "specialists" / f"{file.stem}.md").is_file():
|
|
924
|
+
# The brief exists but failed validation; INVALID was reported.
|
|
925
|
+
continue
|
|
926
|
+
issues.append(f"ORPHAN: {file.stem}")
|
|
927
|
+
|
|
928
|
+
if briefs:
|
|
929
|
+
agents_path = project / "AGENTS.md"
|
|
930
|
+
if agents_path.is_symlink() or (agents_path.exists() and not agents_path.is_file()):
|
|
931
|
+
issues.append("AGENTS.md STALE")
|
|
932
|
+
elif not agents_path.exists():
|
|
933
|
+
issues.append("AGENTS.md MISSING (file)")
|
|
934
|
+
else:
|
|
935
|
+
try:
|
|
936
|
+
content = read_with_newlines(agents_path)
|
|
937
|
+
state, _, _ = classify_agents_block(content)
|
|
938
|
+
except (OSError, UnicodeDecodeError, BriefError):
|
|
939
|
+
issues.append("AGENTS.md STALE")
|
|
940
|
+
else:
|
|
941
|
+
newline = dominant_newline(content)
|
|
942
|
+
if state == "absent":
|
|
943
|
+
issues.append("AGENTS.md MISSING (block)")
|
|
944
|
+
elif render_agents_block(briefs, newline) not in content:
|
|
945
|
+
issues.append("AGENTS.md STALE")
|
|
946
|
+
|
|
947
|
+
if issues:
|
|
948
|
+
print("Agent check found issues:")
|
|
949
|
+
for issue in issues:
|
|
950
|
+
print(f" {issue}")
|
|
951
|
+
return 1
|
|
952
|
+
print(f"OK: {len(roles)} default, {len(briefs)} custom")
|
|
953
|
+
return 0
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def run_remove_custom(project: Path, role: str, yes: bool, dry_run: bool) -> int:
|
|
957
|
+
"""Remove a generated specialist agent and its brief after confirmation.
|
|
958
|
+
|
|
959
|
+
Args:
|
|
960
|
+
project: Resolved project root.
|
|
961
|
+
role: Role requested with `--remove-custom`.
|
|
962
|
+
yes: Confirms the deletion.
|
|
963
|
+
dry_run: When True, report the intended deletions and change nothing.
|
|
964
|
+
|
|
965
|
+
Returns:
|
|
966
|
+
Process exit code (0 on success, 1 on any refusal).
|
|
967
|
+
"""
|
|
968
|
+
try:
|
|
969
|
+
validate_role_name(role)
|
|
970
|
+
project_root = project.resolve()
|
|
971
|
+
opencode_dir = project / ".opencode"
|
|
972
|
+
if opencode_dir.is_symlink():
|
|
973
|
+
raise BriefError(f"refusing to remove through a symlinked directory: {opencode_dir}")
|
|
974
|
+
agents_dir = opencode_dir / "agents"
|
|
975
|
+
if agents_dir.is_symlink():
|
|
976
|
+
raise BriefError(f"refusing to remove through a symlinked directory: {agents_dir}")
|
|
977
|
+
if project_root not in agents_dir.resolve().parents:
|
|
978
|
+
raise BriefError(f"agent directory escapes the project root: {agents_dir}")
|
|
979
|
+
specialists_dir = project / "codeops" / "specialists"
|
|
980
|
+
if specialists_dir.is_symlink():
|
|
981
|
+
raise BriefError(f"refusing to remove through a symlinked directory: {specialists_dir}")
|
|
982
|
+
if project_root not in specialists_dir.resolve().parents:
|
|
983
|
+
raise BriefError(f"specialists directory escapes the project root: {specialists_dir}")
|
|
984
|
+
agent_path = agents_dir / f"{role}.md"
|
|
985
|
+
brief_path = specialists_dir / f"{role}.md"
|
|
986
|
+
if brief_path.is_symlink():
|
|
987
|
+
raise BriefError(f"refusing to remove through a symlinked brief: {brief_path}")
|
|
988
|
+
|
|
989
|
+
agent_is_link = agent_path.is_symlink()
|
|
990
|
+
agent_exists = agent_path.exists() or agent_is_link
|
|
991
|
+
brief_exists = brief_path.is_file()
|
|
992
|
+
if agent_exists and not agent_is_link:
|
|
993
|
+
if not agent_path.is_file():
|
|
994
|
+
raise BriefError(f"refusing to remove a non-regular file: {agent_path}")
|
|
995
|
+
template = generated_custom_template(agent_path)
|
|
996
|
+
if not template or not template.startswith(CUSTOM_TEMPLATE_PREFIX):
|
|
997
|
+
raise BriefError(f"refusing to remove {role!r}: not a generated specialist agent")
|
|
998
|
+
if not agent_exists and not brief_exists:
|
|
999
|
+
raise BriefError(f"nothing to remove for role {role!r}")
|
|
1000
|
+
|
|
1001
|
+
briefs, errors = collect_briefs(project)
|
|
1002
|
+
if errors:
|
|
1003
|
+
for error in errors:
|
|
1004
|
+
print(error)
|
|
1005
|
+
return 1
|
|
1006
|
+
remaining = {name: brief for name, brief in briefs.items() if name != role}
|
|
1007
|
+
agents_path, new_content, error = compute_agents_md(project, remaining)
|
|
1008
|
+
if error:
|
|
1009
|
+
raise BriefError(error)
|
|
1010
|
+
if agents_path.exists() and not os.access(agents_path, os.W_OK):
|
|
1011
|
+
raise BriefError(f"AGENTS.md is not writable: {agents_path}")
|
|
1012
|
+
|
|
1013
|
+
if not yes or dry_run:
|
|
1014
|
+
if agent_exists:
|
|
1015
|
+
print(f"Would delete: {agent_path}")
|
|
1016
|
+
else:
|
|
1017
|
+
print(f"MISSING: {role} (no generated agent)")
|
|
1018
|
+
if brief_exists:
|
|
1019
|
+
print(f"Would delete: {brief_path}")
|
|
1020
|
+
else:
|
|
1021
|
+
print(f"ORPHAN: {role} (generated agent without a brief)")
|
|
1022
|
+
print("Pass --yes to delete (dry-run always wins).")
|
|
1023
|
+
return 0
|
|
1024
|
+
|
|
1025
|
+
if agent_is_link:
|
|
1026
|
+
os.unlink(agent_path)
|
|
1027
|
+
elif agent_exists:
|
|
1028
|
+
agent_path.unlink()
|
|
1029
|
+
if brief_exists:
|
|
1030
|
+
brief_path.unlink()
|
|
1031
|
+
if new_content is not None:
|
|
1032
|
+
write_generated_file(agents_path, new_content)
|
|
1033
|
+
print(f"Removed: {role}")
|
|
1034
|
+
return 0
|
|
1035
|
+
except BriefError as exc:
|
|
1036
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
1037
|
+
return 1
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def run_custom(
|
|
1041
|
+
project: Path,
|
|
1042
|
+
plugin_root: Path,
|
|
1043
|
+
role: str,
|
|
1044
|
+
routing_roles: dict,
|
|
1045
|
+
dry_run: bool,
|
|
1046
|
+
) -> int:
|
|
1047
|
+
"""Generate one specialist agent from its brief, or report why it cannot.
|
|
1048
|
+
|
|
1049
|
+
Every check happens before the first write: naming, brief resolution
|
|
1050
|
+
(symlinks refused), brief validation, and target path safety (symlinked
|
|
1051
|
+
directories or files, paths escaping the project root, non-regular files,
|
|
1052
|
+
and hand-authored files are all refused). The final write opens the target
|
|
1053
|
+
with `O_NOFOLLOW` so a last-moment symlink swap cannot redirect it. A
|
|
1054
|
+
failure reports on stderr and returns 1 with the project unchanged.
|
|
1055
|
+
|
|
1056
|
+
Args:
|
|
1057
|
+
project: Resolved project root.
|
|
1058
|
+
plugin_root: Package root containing `agent-templates/`.
|
|
1059
|
+
role: Role requested with `--custom`.
|
|
1060
|
+
routing_roles: Parsed `routing.roles` policy from `codeops.json`.
|
|
1061
|
+
dry_run: When True, report the intended write and change nothing.
|
|
1062
|
+
|
|
1063
|
+
Returns:
|
|
1064
|
+
Process exit code (0 on success, 1 on any validation failure).
|
|
1065
|
+
"""
|
|
1066
|
+
try:
|
|
1067
|
+
validate_role_name(role)
|
|
1068
|
+
project_root = project.resolve()
|
|
1069
|
+
brief_dir = project / "codeops" / "specialists"
|
|
1070
|
+
if brief_dir.is_symlink():
|
|
1071
|
+
raise BriefError(f"refusing to read through a symlinked directory: {brief_dir}")
|
|
1072
|
+
brief_path = brief_dir / f"{role}.md"
|
|
1073
|
+
if brief_path.is_symlink():
|
|
1074
|
+
raise BriefError(f"refusing to read through a symlinked brief: {brief_path}")
|
|
1075
|
+
if not brief_path.is_file():
|
|
1076
|
+
raise BriefError(f"brief not found: {brief_path}")
|
|
1077
|
+
if project_root not in brief_path.resolve().parents:
|
|
1078
|
+
raise BriefError(f"brief path escapes the project root: {brief_path}")
|
|
1079
|
+
brief = parse_brief(brief_path, role)
|
|
1080
|
+
|
|
1081
|
+
opencode_dir = project / ".opencode"
|
|
1082
|
+
if opencode_dir.is_symlink():
|
|
1083
|
+
raise BriefError(f"refusing to write through a symlinked directory: {opencode_dir}")
|
|
1084
|
+
agents_dir = opencode_dir / "agents"
|
|
1085
|
+
if agents_dir.is_symlink():
|
|
1086
|
+
raise BriefError(f"refusing to write through a symlinked directory: {agents_dir}")
|
|
1087
|
+
if project_root not in agents_dir.resolve().parents:
|
|
1088
|
+
raise BriefError(f"agent directory escapes the project root: {agents_dir}")
|
|
1089
|
+
out_path = agents_dir / f"{role}.md"
|
|
1090
|
+
if out_path.is_symlink():
|
|
1091
|
+
raise BriefError(f"refusing to write through a symlinked file: {out_path}")
|
|
1092
|
+
if out_path.exists() and not out_path.is_file():
|
|
1093
|
+
raise BriefError(f"refusing to write to a non-regular file: {out_path}")
|
|
1094
|
+
if out_path.is_file() and not is_codeops_generated(out_path):
|
|
1095
|
+
raise BriefError(
|
|
1096
|
+
f"refusing to overwrite hand-authored file: {out_path} "
|
|
1097
|
+
"(rename or remove it manually first)"
|
|
1098
|
+
)
|
|
1099
|
+
|
|
1100
|
+
content = generate_custom_agent(plugin_root, role, brief, routing_roles.get(role, {}))
|
|
1101
|
+
if dry_run:
|
|
1102
|
+
print(f" [dry-run] would write: {out_path}")
|
|
1103
|
+
print(f" kind={brief['kind']}, reasoning={routing_roles.get(role, {}).get('reasoning', brief.get('reasoning') or 'max')}")
|
|
1104
|
+
return 0
|
|
1105
|
+
agents_dir.mkdir(parents=True, exist_ok=True)
|
|
1106
|
+
write_generated_file(out_path, content)
|
|
1107
|
+
print(f" wrote: {out_path.name}")
|
|
1108
|
+
return 0
|
|
1109
|
+
except BriefError as exc:
|
|
1110
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
1111
|
+
return 1
|
|
1112
|
+
|
|
1113
|
+
|
|
190
1114
|
def main() -> int:
|
|
191
1115
|
parser = argparse.ArgumentParser(
|
|
192
1116
|
description="Generate OpenCode agent files from CodeOps routing config."
|
|
193
1117
|
)
|
|
194
1118
|
parser.add_argument("--project", required=True, help="Path to the project root")
|
|
195
1119
|
parser.add_argument("--roles", help="Comma-separated list of roles to install (default: all)")
|
|
196
|
-
parser.
|
|
1120
|
+
mode = parser.add_mutually_exclusive_group()
|
|
1121
|
+
mode.add_argument(
|
|
1122
|
+
"--custom",
|
|
1123
|
+
metavar="ROLE",
|
|
1124
|
+
help="Generate one project specialist agent from codeops/specialists/ROLE.md",
|
|
1125
|
+
)
|
|
1126
|
+
mode.add_argument(
|
|
1127
|
+
"--remove-custom",
|
|
1128
|
+
metavar="ROLE",
|
|
1129
|
+
help="Delete a generated specialist agent and its brief (needs --yes)",
|
|
1130
|
+
)
|
|
1131
|
+
mode.add_argument(
|
|
1132
|
+
"--sync-agents-md",
|
|
1133
|
+
action="store_true",
|
|
1134
|
+
help="Render or update the managed specialist block in AGENTS.md",
|
|
1135
|
+
)
|
|
1136
|
+
mode.add_argument("--check", action="store_true", help="Check for missing or stale agents without writing")
|
|
1137
|
+
parser.add_argument("--yes", action="store_true", help="Confirm destructive --remove-custom deletions")
|
|
197
1138
|
parser.add_argument("--dry-run", action="store_true", help="Show what would be written without writing")
|
|
198
1139
|
args = parser.parse_args()
|
|
199
1140
|
|
|
@@ -231,25 +1172,16 @@ def main() -> int:
|
|
|
231
1172
|
)
|
|
232
1173
|
return 1
|
|
233
1174
|
|
|
234
|
-
|
|
235
|
-
|
|
1175
|
+
if args.custom:
|
|
1176
|
+
return run_custom(project, plugin_root, args.custom, routing_roles, args.dry_run)
|
|
1177
|
+
if args.remove_custom:
|
|
1178
|
+
return run_remove_custom(project, args.remove_custom, args.yes, args.dry_run)
|
|
1179
|
+
if args.sync_agents_md:
|
|
1180
|
+
return run_sync_agents_md(project, args.dry_run)
|
|
236
1181
|
if args.check:
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
out_path = agents_dir / f"{role}.md"
|
|
241
|
-
if not out_path.exists():
|
|
242
|
-
issues.append(f"MISSING: {out_path}")
|
|
243
|
-
elif not is_codeops_generated(out_path):
|
|
244
|
-
issues.append(f"HAND-AUTHORED (skip): {out_path}")
|
|
245
|
-
if issues:
|
|
246
|
-
print("Agent check found issues:")
|
|
247
|
-
for i in issues:
|
|
248
|
-
print(f" {i}")
|
|
249
|
-
return 1
|
|
250
|
-
else:
|
|
251
|
-
print(f"OK: all {len(roles)} CodeOps agent files present.")
|
|
252
|
-
return 0
|
|
1182
|
+
return run_check(project, plugin_root, roles, routing_roles)
|
|
1183
|
+
|
|
1184
|
+
agents_dir = project / ".opencode" / "agents"
|
|
253
1185
|
|
|
254
1186
|
# Generate and write (or dry-run)
|
|
255
1187
|
if not args.dry_run:
|