opencode-codeops 1.7.1 → 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.
@@ -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
- if not path.exists():
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.add_argument("--check", action="store_true", help="Check for missing or stale agents without writing")
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
- agents_dir = project / ".opencode" / "agents"
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
- # Check mode: report missing or stale files
238
- issues = []
239
- for role in roles:
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: