From d4b8003761e62686769f4c273c04d13b24d627a3 Mon Sep 17 00:00:00 2001 From: monkut Date: Sat, 25 Apr 2026 18:54:51 +0900 Subject: [PATCH] :sparkles: Add subagent frontmatter to agent action definitions (refs #80) Adopts a Claude Code subagent-style YAML frontmatter on each *_SYSTEM_PROMPT.md template so per-action tool surface, model, and reasoning effort are declarative rather than uniform. - AgentConfig gains optional fields: tools, disallowed_tools, model, effort, max_thinking_tokens, max_turns - Stdlib-only frontmatter parser (no new runtime deps); validates enum fields (model, effort) at load time with clear errors - load_agent_config splits frontmatter from body; templates without frontmatter continue to work unchanged (back-compat) - ClaudeRunner emits --model, --allowedTools, --disallowedTools, --max-turns from AgentConfig - CLI precedence (highest wins): explicit CLI > env > frontmatter > built-in default - Per-action defaults match the issue's table; permission_mode is intentionally not exposed since askcc runs unattended with --dangerously-skip-permissions (the tools allowlist is the per-action safety boundary) - Bumps version to 0.2.6 --- README.md | 56 +++++++++++ askcc/cli.py | 48 +++++++-- askcc/definitions.py | 80 +++++++++++++++ askcc/functions.py | 109 ++++++++++++++++++-- askcc/runners.py | 16 ++- pyproject.toml | 2 +- tests/test_askcc.py | 229 ++++++++++++++++++++++++++++++++++++++++++- uv.lock | 2 +- 8 files changed, 522 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index ce6e201..5a4d673 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,62 @@ Edit any file to customize the agent's behavior. User prompt templates **must** Override the config directory by setting the `ASKCC_HOME` environment variable (e.g. for testing). +#### Subagent Frontmatter + +Each `*_SYSTEM_PROMPT.md` template may begin with a [Claude Code subagent](https://code.claude.com/docs/en/subagents.md)–style YAML frontmatter block that declares the agent's tool surface, model, and reasoning effort: + +```markdown +--- +name: develop +description: Develops a planned/defined issue +tools: Read, Write, Edit, Bash, Grep, Glob +model: opus +effort: max +max_thinking_tokens: 32000 +max_turns: 200 +--- +You are an expert software developer ... +``` + +Recognized fields (all optional): + +| Field | Type | Allowed values | +|---|---|---| +| `name` | string | informational | +| `description` | string | informational | +| `tools` | comma-separated list | translated to `--allowedTools` (e.g. `Read, Bash(gh:*)`) | +| `disallowed_tools` | comma-separated list | translated to `--disallowedTools` | +| `model` | string | `opus`, `sonnet`, `haiku`, `inherit` | +| `effort` | string | `low`, `medium`, `high`, `xhigh`, `max` | +| `max_thinking_tokens` | integer | thinking token budget | +| `max_turns` | integer | translated to `--max-turns` | + +Templates without frontmatter continue to work unchanged. Invalid values (e.g. `model: opuz`, `effort: turbo`) raise a clear error at load time rather than mid-run. + +##### Per-Action Defaults + +| Action | tools | model | effort | +|---|---|---|---| +| `prepare` | `Read, Grep, Glob, Bash(gh:*)` | `sonnet` | `medium` | +| `plan` | `Read, Grep, Glob, Bash(gh:*)` | `opus` | `high` | +| `develop` | `Read, Write, Edit, Bash, Grep, Glob` | `opus` | `max` | +| `issue-review` | `Read, Grep, Glob, Bash(gh:*)` | `sonnet` | `medium` | +| `pr-review` | `Read, Grep, Glob, Bash(gh:*,git:*)` | `opus` | `high` | +| `explore` | `Read, Grep, Glob, Bash(gh:*)` | `sonnet` | `high` | +| `diagnose` | `Read, Grep, Glob, Bash(gh:*,git:*)` | `sonnet` | `high` | +| `fix-ci` | `Read, Write, Edit, Bash, Grep, Glob` | `sonnet` | `high` | + +Note: askcc runs `claude` with `--dangerously-skip-permissions` so it can execute unattended; the per-action `tools` allowlist is the safety boundary that narrows what each agent can call. + +##### Override Precedence + +For `effort` and `max_thinking_tokens`, askcc resolves the effective value in this order (highest wins): + +1. Explicit CLI flag (`--effort`, `--max-thinking-tokens`) +2. Environment variable (`ASKCC_CLAUDE_EFFORT_LEVEL`, `ASKCC_CLAUDE_MAX_THINKING_TOKENS`) +3. Template frontmatter (per-action default in `~/.askcc/templates/`) +4. Built-in default (`xhigh`, `21000`) + ### Post-Develop Verification After the `develop` command completes successfully, askcc can run verification commands (tests, linting, type checks) before transitioning the issue to `action:review`. This is **optional** — if no verification config is found, the transition proceeds without checks. diff --git a/askcc/cli.py b/askcc/cli.py index dfc9c0a..b82392a 100644 --- a/askcc/cli.py +++ b/askcc/cli.py @@ -1,5 +1,6 @@ import argparse import logging +import os import subprocess import sys import tempfile @@ -55,6 +56,33 @@ def _build_prompt( return prompt, tempfiles +def _resolve_effort(cli_effort: str | None, frontmatter_effort: str | None) -> str: + """Precedence: CLI flag > env var > template frontmatter > built-in default.""" + if cli_effort is not None: + return cli_effort + env_value = os.environ.get("ASKCC_CLAUDE_EFFORT_LEVEL", "") + if env_value: + try: + return VALID_EFFORT_LEVELS(env_value).value + except ValueError: + logger.warning("Invalid ASKCC_CLAUDE_EFFORT_LEVEL=%r — falling through to frontmatter/default", env_value) + if frontmatter_effort is not None: + return frontmatter_effort + return settings.DEFAULT_EFFORT_LEVEL + + +def _resolve_max_thinking_tokens(cli_value: int | None, frontmatter_value: int | None) -> int: + """Precedence: CLI flag > env var > template frontmatter > built-in default.""" + if cli_value is not None: + return cli_value + raw_env = os.environ.get("ASKCC_CLAUDE_MAX_THINKING_TOKENS", "") + if raw_env.isdigit(): + return int(raw_env) + if frontmatter_value is not None: + return frontmatter_value + return settings.DEFAULT_MAX_THINKING_TOKENS + + def _print_validation_report(issue_url: str, checks: list[CheckResult]) -> None: """Print a structured pass/fail validation report.""" passed_count = sum(1 for c in checks if c.passed) @@ -108,16 +136,18 @@ def main() -> None: # noqa: PLR0912, PLR0915, C901 parser.add_argument( "--effort", choices=VALID_EFFORT_LEVELS, - default=settings.ASKCC_CLAUDE_EFFORT_LEVEL, - help=f"Claude thinking effort level (default: {settings.ASKCC_CLAUDE_EFFORT_LEVEL}). " - "Env: ASKCC_CLAUDE_EFFORT_LEVEL.", + default=None, + help=f"Claude thinking effort level. " + f"Precedence: CLI > env (ASKCC_CLAUDE_EFFORT_LEVEL) > template frontmatter > " + f"built-in default ({settings.DEFAULT_EFFORT_LEVEL}).", ) parser.add_argument( "--max-thinking-tokens", type=int, - default=settings.ASKCC_CLAUDE_MAX_THINKING_TOKENS, - help=f"Thinking token budget (default: {settings.ASKCC_CLAUDE_MAX_THINKING_TOKENS}). " - "Env: ASKCC_CLAUDE_MAX_THINKING_TOKENS.", + default=None, + help=f"Thinking token budget. " + f"Precedence: CLI > env (ASKCC_CLAUDE_MAX_THINKING_TOKENS) > template frontmatter > " + f"built-in default ({settings.DEFAULT_MAX_THINKING_TOKENS}).", ) parser.add_argument( "--disable-thinking", @@ -250,14 +280,16 @@ def main() -> None: # noqa: PLR0912, PLR0915, C901 if args.language != SupportedLanguage.ENGLISH: prompt += f"\nOutput all comments in {args.language}." runner = get_runner(args.runner) + effort_level = _resolve_effort(args.effort, config.effort) + max_thinking_tokens = _resolve_max_thinking_tokens(args.max_thinking_tokens, config.max_thinking_tokens) try: return_code, usage = runner.run( prompt, config=config, issue_url=issue_url, cwd=cwd, - effort_level=args.effort, - max_thinking_tokens=args.max_thinking_tokens, + effort_level=effort_level, + max_thinking_tokens=max_thinking_tokens, disable_thinking=args.disable_thinking, disable_adaptive_thinking=args.disable_adaptive_thinking, ) diff --git a/askcc/definitions.py b/askcc/definitions.py index db19c59..7cfc36a 100644 --- a/askcc/definitions.py +++ b/askcc/definitions.py @@ -34,6 +34,13 @@ PREPARE_AGENT_PROMPT = ( """\ +--- +name: prepare +description: Analyzes a backlog issue for development readiness and suggests improvements +tools: Read, Grep, Glob, Bash(gh:*) +model: sonnet +effort: medium +--- You are an issue preparation specialist operating inside Claude Code with access to the filesystem, git, and the gh CLI. Goal: Analyze the given GitHub issue for development readiness and post a structured preparation comment \ @@ -108,6 +115,13 @@ PLAN_AGENT_PROMPT = ( """\ +--- +name: plan +description: Plans implementation for given issue +tools: Read, Grep, Glob, Bash(gh:*) +model: opus +effort: high +--- You are a software architect operating inside Claude Code with access to the filesystem, git, and the gh CLI. Goal: Analyze the given GitHub issue against this project's codebase and produce a structured implementation plan. @@ -164,7 +178,19 @@ """ ) +# NOTE: develop and fix-ci require write access (Edit/Write/Bash) to actually +# implement changes and run tests. The runner currently passes +# `--dangerously-skip-permissions` globally so all actions run unattended; the +# tools allowlist below is the per-action safety boundary for these write-capable +# agents. DEVELOP_AGENT_PROMPT = f"""\ +--- +name: develop +description: Develops a planned/defined issue +tools: Read, Write, Edit, Bash, Grep, Glob +model: opus +effort: max +--- You are an expert software developer operating inside Claude Code with access to the filesystem, git, and the gh CLI. Goal: Implement the planned GitHub issue, open a pull request, and link it back to the issue. @@ -260,6 +286,13 @@ REVIEW_AGENT_PROMPT = ( """\ +--- +name: issue-review +description: Reviews a GitHub issue for clarity, completeness, and feasibility +tools: Read, Grep, Glob, Bash(gh:*) +model: sonnet +effort: medium +--- You are an issue reviewer operating inside Claude Code with access to the filesystem, git, and the gh CLI. Goal: Review the given GitHub issue for clarity, completeness, and feasibility, then post actionable feedback \ @@ -297,6 +330,13 @@ EXPLORE_AGENT_PROMPT = ( """\ +--- +name: explore +description: Investigates a GitHub issue and proposes best-practice solutions +tools: Read, Grep, Glob, Bash(gh:*) +model: sonnet +effort: high +--- You are a solutions architect operating inside Claude Code with access to the filesystem, git, and the gh CLI. Goal: Investigate the given GitHub issue, research the codebase, and propose best-practice solutions with trade-offs. @@ -351,6 +391,13 @@ DIAGNOSE_AGENT_PROMPT = ( """\ +--- +name: diagnose +description: Investigates a reported issue and identifies potential causes +tools: Read, Grep, Glob, Bash(gh:*,git:*) +model: sonnet +effort: high +--- You are a diagnostic engineer operating inside Claude Code with access to the filesystem, git, and the gh CLI. Goal: Investigate the reported issue, identify potential root causes, flag unknowns, and request additional \ @@ -403,6 +450,13 @@ REVIEWPR_AGENT_PROMPT = ( """\ +--- +name: pr-review +description: Reviews a pull request against its linked issue's Definition of Done +tools: Read, Grep, Glob, Bash(gh:*,git:*) +model: opus +effort: high +--- You are a code reviewer operating inside Claude Code with access to the filesystem, git, and the gh CLI. Goal: Review the pull request linked to the given GitHub issue, verify it meets the Definition of Done, \ @@ -476,7 +530,17 @@ ) +# See note above DEVELOP_AGENT_PROMPT — fix-ci is a write-capable action that +# needs Edit/Write/Bash to apply CI fixes; rely on the tools allowlist below for +# the per-action safety boundary. FIXCI_AGENT_PROMPT = """\ +--- +name: fix-ci +description: Identifies failing CI checks on the current PR or branch and implements fixes +tools: Read, Write, Edit, Bash, Grep, Glob +model: sonnet +effort: high +--- You are a CI fix specialist operating inside Claude Code with access to the filesystem, git, and the gh CLI. Goal: Identify failing CI checks on the current PR or branch and implement fixes to make them pass. @@ -542,6 +606,22 @@ class AgentConfig: system_prompt_file: str user_prompt_file: str required_variables: tuple[str, ...] = () + # Subagent-style frontmatter fields — populated from the system_prompt's + # leading `---`-delimited block by load_agent_config when present. + tools: tuple[str, ...] | None = None + disallowed_tools: tuple[str, ...] | None = None + model: str | None = None + effort: str | None = None + max_thinking_tokens: int | None = None + max_turns: int | None = None + + +# Allowed values for frontmatter enum fields (validated at load time). +VALID_FRONTMATTER_MODELS: tuple[str, ...] = ("opus", "sonnet", "haiku", "inherit") +# Frontmatter keys recognized by the parser. Unknown keys are warned and ignored. +KNOWN_FRONTMATTER_KEYS: frozenset[str] = frozenset( + {"name", "description", "tools", "disallowed_tools", "model", "effort", "max_thinking_tokens", "max_turns"} +) class SupportedLanguage(StrEnum): diff --git a/askcc/functions.py b/askcc/functions.py index a5c9872..d5473cd 100644 --- a/askcc/functions.py +++ b/askcc/functions.py @@ -12,7 +12,13 @@ from string import Template from urllib.parse import urlparse -from .definitions import AGENT_CONFIGS, AgentAction, AgentConfig +from .definitions import ( + AGENT_CONFIGS, + KNOWN_FRONTMATTER_KEYS, + VALID_FRONTMATTER_MODELS, + AgentAction, + AgentConfig, +) from .settings import ( BLOCKING_LABELS, DEVELOP_LABEL, @@ -24,6 +30,7 @@ REVIEW_LABEL, REVIEW_STATUS_OPTIONS, TEMPLATES_DIR, + VALID_EFFORT_LEVELS, ) logger = logging.getLogger(__name__) @@ -774,13 +781,101 @@ def write_prompt_content( return filepath +FRONTMATTER_DELIMITER = "---" +_LIST_FRONTMATTER_FIELDS: frozenset[str] = frozenset({"tools", "disallowed_tools"}) +_INT_FRONTMATTER_FIELDS: frozenset[str] = frozenset({"max_thinking_tokens", "max_turns"}) + + +def _coerce_frontmatter_value(key: str, value: str, source: str) -> object: + """Coerce a raw frontmatter value to its typed form (list/int/string).""" + if key in _LIST_FRONTMATTER_FIELDS: + return tuple(item.strip() for item in value.split(",") if item.strip()) if value else () + if key in _INT_FRONTMATTER_FIELDS: + try: + return int(value) + except ValueError as exc: + msg = f"Frontmatter field '{key}' in {source} must be an integer, got {value!r}" + raise ValueError(msg) from exc + return value + + +def _validate_frontmatter_enums(fields: dict, source: str) -> None: + """Raise ValueError when enum-valued fields contain unsupported values.""" + if "model" in fields and fields["model"] not in VALID_FRONTMATTER_MODELS: + msg = ( + f"Frontmatter field 'model' in {source} has invalid value {fields['model']!r}" + f" (allowed: {', '.join(VALID_FRONTMATTER_MODELS)})" + ) + raise ValueError(msg) + if "effort" in fields: + try: + VALID_EFFORT_LEVELS(fields["effort"]) + except ValueError as exc: + msg = ( + f"Frontmatter field 'effort' in {source} has invalid value {fields['effort']!r}" + f" (allowed: {', '.join(VALID_EFFORT_LEVELS)})" + ) + raise ValueError(msg) from exc + + +def parse_frontmatter(text: str, *, source: str = "") -> tuple[dict, str]: + r"""Split a Claude Code subagent-style YAML frontmatter block from its body. + + Recognizes a leading `---\n...\n---\n` block. Supports flat `key: value` + lines only — no nested mappings or multi-line values. List fields are + comma-separated; int fields are parsed as integers. + + Returns (parsed_fields, body_without_frontmatter). When no frontmatter is + present, returns ({}, text) unchanged for back-compat. + """ + lines = text.splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != FRONTMATTER_DELIMITER: + return {}, text + + end_idx = next( + (i for i in range(1, len(lines)) if lines[i].rstrip("\r\n") == FRONTMATTER_DELIMITER), + None, + ) + if end_idx is None: + msg = f"Frontmatter in {source} is missing the closing '---' delimiter" + raise ValueError(msg) + + fields: dict = {} + for raw_line in lines[1:end_idx]: + line = raw_line.rstrip("\r\n") + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if ":" not in line: + msg = f"Frontmatter in {source} has malformed line (no ':'): {line!r}" + raise ValueError(msg) + key, _, value = line.partition(":") + key = key.strip() + if key not in KNOWN_FRONTMATTER_KEYS: + logger.warning("Unknown frontmatter key in %s: %r — ignoring", source, key) + continue + fields[key] = _coerce_frontmatter_value(key, value.strip(), source) + + _validate_frontmatter_enums(fields, source) + body = "".join(lines[end_idx + 1 :]) + return fields, body + + def load_agent_config(agent: AgentAction) -> AgentConfig: - """Load an AgentConfig with templates read from disk, falling back to built-in defaults.""" + """Load an AgentConfig with templates read from disk, falling back to built-in defaults. + + If the loaded system_prompt begins with a `---`-delimited frontmatter block, + its fields override the AgentConfig's defaults (model, effort, tools, etc.) + and the body becomes the system_prompt. Templates without frontmatter are + returned unchanged. + """ base = AGENT_CONFIGS[agent] user_prompt_template = load_template(base.user_prompt_file, base.user_prompt_template) validate_template(user_prompt_template, base.required_variables, base.user_prompt_file) - return replace( - base, - system_prompt=load_template(base.system_prompt_file, base.system_prompt), - user_prompt_template=user_prompt_template, - ) + raw_system_prompt = load_template(base.system_prompt_file, base.system_prompt) + fields, body = parse_frontmatter(raw_system_prompt, source=base.system_prompt_file) + overrides: dict = {"system_prompt": body, "user_prompt_template": user_prompt_template} + for key in ("tools", "disallowed_tools", "model", "effort", "max_thinking_tokens", "max_turns"): + if key in fields: + overrides[key] = fields[key] + return replace(base, **overrides) diff --git a/askcc/runners.py b/askcc/runners.py index b6c3af9..d435cb8 100644 --- a/askcc/runners.py +++ b/askcc/runners.py @@ -38,6 +38,20 @@ def run( """Execute a prompt and return (exit_code, usage_dict_or_none).""" +def _frontmatter_cli_flags(config: AgentConfig) -> list[str]: + """Translate AgentConfig frontmatter fields into claude CLI flags.""" + flags: list[str] = [] + if config.model: + flags.extend(["--model", config.model]) + if config.tools: + flags.extend(["--allowedTools", ",".join(config.tools)]) + if config.disallowed_tools: + flags.extend(["--disallowedTools", ",".join(config.disallowed_tools)]) + if config.max_turns is not None: + flags.extend(["--max-turns", str(config.max_turns)]) + return flags + + class ClaudeRunner(Runner): """Runs tasks via the Claude Code CLI.""" @@ -66,9 +80,9 @@ def run( "--agents", json.dumps(agent_definition), ] - if effort_level: cmd.extend(["--effort", effort_level]) + cmd.extend(_frontmatter_cli_flags(config)) # Remove CLAUDECODE env var so the child claude process doesn't think it's nested inside Claude Code env = os.environ.copy() diff --git a/pyproject.toml b/pyproject.toml index f533080..f0f700e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "askcc" -version = "0.2.5" +version = "0.2.6" description = "A one-shot cc cli executor" authors = [{ name = "mknt", email = "shane.cousins@gmail.com" }] readme = "README.md" diff --git a/tests/test_askcc.py b/tests/test_askcc.py index bf087c2..7cf4a69 100644 --- a/tests/test_askcc.py +++ b/tests/test_askcc.py @@ -36,6 +36,7 @@ install_skills, load_agent_config, load_template, + parse_frontmatter, transition_issue_to_development, transition_issue_to_planning, transition_issue_to_review, @@ -1678,13 +1679,13 @@ def test_no_disable_adaptive_thinking_flag(self): assert call_kwargs.kwargs["disable_adaptive_thinking"] is False def test_effort_env_default_used(self, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr("askcc.cli.settings.ASKCC_CLAUDE_EFFORT_LEVEL", "low") + monkeypatch.setenv("ASKCC_CLAUDE_EFFORT_LEVEL", "low") mock_runner = self._run_main_with_args([]) call_kwargs = mock_runner.run.call_args assert call_kwargs.kwargs["effort_level"] == "low" def test_cli_flag_overrides_env_default(self, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr("askcc.cli.settings.ASKCC_CLAUDE_EFFORT_LEVEL", "low") + monkeypatch.setenv("ASKCC_CLAUDE_EFFORT_LEVEL", "low") mock_runner = self._run_main_with_args(["--effort", "max"]) call_kwargs = mock_runner.run.call_args assert call_kwargs.kwargs["effort_level"] == "max" @@ -1809,3 +1810,227 @@ def test_disable_thinking_false_not_set(self, runner: ClaudeRunner, agent_config ) env = mock_run.call_args[1]["env"] assert CLAUDE_ENV_DISABLE_THINKING not in env + + +class TestParseFrontmatter: + """Tests for the YAML-style subagent frontmatter parser.""" + + def _parse(self, text: str) -> tuple[dict, str]: + return parse_frontmatter(text, source="test.md") + + def test_no_frontmatter_returns_empty_and_full_text(self): + text = "Just a plain prompt body.\nNo frontmatter here.\n" + fields, body = self._parse(text) + assert fields == {} + assert body == text + + def test_basic_frontmatter_parsed(self): + text = "---\nname: develop\nmodel: opus\neffort: max\n---\nPrompt body here.\n" + fields, body = self._parse(text) + assert fields["name"] == "develop" + assert fields["model"] == "opus" + assert fields["effort"] == "max" + assert body == "Prompt body here.\n" + + def test_tools_parsed_as_tuple(self): + text = "---\ntools: Read, Write, Bash(gh:*)\n---\nbody\n" + fields, _ = self._parse(text) + assert fields["tools"] == ("Read", "Write", "Bash(gh:*)") + + def test_int_field_parsed(self): + text = "---\nmax_thinking_tokens: 32000\nmax_turns: 200\n---\nbody\n" + fields, _ = self._parse(text) + assert fields["max_thinking_tokens"] == 32000 + assert fields["max_turns"] == 200 + + def test_int_field_invalid_raises(self): + text = "---\nmax_thinking_tokens: not-a-number\n---\nbody\n" + with pytest.raises(ValueError, match="must be an integer"): + self._parse(text) + + def test_invalid_model_raises(self): + text = "---\nmodel: opuz\n---\nbody\n" + with pytest.raises(ValueError, match="model.*invalid value"): + self._parse(text) + + def test_invalid_effort_raises(self): + text = "---\neffort: turbo\n---\nbody\n" + with pytest.raises(ValueError, match="effort.*invalid value"): + self._parse(text) + + def test_unclosed_frontmatter_raises(self): + text = "---\nname: develop\nbody never ends with closing delimiter\n" + with pytest.raises(ValueError, match="missing the closing"): + self._parse(text) + + def test_unknown_key_warned_and_ignored(self, caplog: pytest.LogCaptureFixture): + text = "---\nname: develop\nmade_up_field: foo\n---\nbody\n" + with caplog.at_level("WARNING", logger="askcc.functions"): + fields, _ = self._parse(text) + assert "made_up_field" not in fields + assert "Unknown frontmatter key" in caplog.text + + def test_blank_and_comment_lines_skipped(self): + text = "---\n# a comment\nname: develop\n\nmodel: opus\n---\nbody\n" + fields, _ = self._parse(text) + assert fields == {"name": "develop", "model": "opus"} + + +class TestLoadAgentConfigFrontmatter: + """Tests that load_agent_config picks up frontmatter overrides from disk.""" + + def test_default_template_yields_frontmatter_fields(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + templates_dir = tmp_path / "templates" + monkeypatch.setattr("askcc.functions.TEMPLATES_DIR", templates_dir) + bootstrap_templates() + + config = load_agent_config(AgentAction.DEVELOP) + assert config.model == "opus" + assert config.effort == "max" + assert "Edit" in (config.tools or ()) + # Frontmatter should be stripped from the system prompt body + assert not config.system_prompt.startswith("---") + + def test_template_without_frontmatter_back_compat(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + templates_dir = tmp_path / "templates" + templates_dir.mkdir(parents=True) + monkeypatch.setattr("askcc.functions.TEMPLATES_DIR", templates_dir) + + # User-style template with no frontmatter + (templates_dir / "PLAN_SYSTEM_PROMPT.md").write_text("Plain system prompt with no frontmatter.\n") + (templates_dir / "PLAN_USER_PROMPT.md").write_text("Read $issue_content_file and plan.\n") + + config = load_agent_config(AgentAction.PLAN) + assert config.system_prompt == "Plain system prompt with no frontmatter.\n" + assert config.model is None + assert config.effort is None + assert config.tools is None + + def test_user_frontmatter_overrides_default(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + templates_dir = tmp_path / "templates" + templates_dir.mkdir(parents=True) + monkeypatch.setattr("askcc.functions.TEMPLATES_DIR", templates_dir) + + custom = "---\nmodel: haiku\neffort: low\n---\nCustom plan body.\n" + (templates_dir / "PLAN_SYSTEM_PROMPT.md").write_text(custom) + (templates_dir / "PLAN_USER_PROMPT.md").write_text("Read $issue_content_file and plan.\n") + + config = load_agent_config(AgentAction.PLAN) + assert config.model == "haiku" + assert config.effort == "low" + assert config.system_prompt == "Custom plan body.\n" + + def test_invalid_frontmatter_raises_at_load(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + templates_dir = tmp_path / "templates" + templates_dir.mkdir(parents=True) + monkeypatch.setattr("askcc.functions.TEMPLATES_DIR", templates_dir) + + bad = "---\nmodel: opuz\n---\nbody\n" + (templates_dir / "PLAN_SYSTEM_PROMPT.md").write_text(bad) + (templates_dir / "PLAN_USER_PROMPT.md").write_text("Read $issue_content_file and plan.\n") + + with pytest.raises(ValueError, match="model.*invalid value"): + load_agent_config(AgentAction.PLAN) + + +class TestEffortPrecedence: + """Tests for CLI > env > frontmatter > built-in default precedence.""" + + ISSUE_URL = "https://github.com/monkut/askcc-cli/issues/1" + + def _run_main(self, args: list[str], frontmatter_effort: str | None = "high") -> MagicMock: + mock_runner = _mock_runner() + # Build a fake AgentConfig that overrides the default frontmatter effort + base = AGENT_CONFIGS[AgentAction.PLAN] + fake_config = AgentConfig( + action_name=base.action_name, + description=base.description, + system_prompt="body", + user_prompt_template=base.user_prompt_template, + system_prompt_file=base.system_prompt_file, + user_prompt_file=base.user_prompt_file, + required_variables=base.required_variables, + effort=frontmatter_effort, + ) + with ( + patch("askcc.cli.bootstrap_templates"), + patch("askcc.cli.validate_issue_labels", return_value=[]), + patch("askcc.cli.fetch_github_issue", return_value="issue body"), + patch("askcc.cli.load_agent_config", return_value=fake_config), + patch("askcc.cli.transition_issue_to_development"), + patch("askcc.cli.get_runner", return_value=mock_runner), + patch("sys.argv", ["askcc", *args, "plan", "-g", self.ISSUE_URL]), + pytest.raises(SystemExit), + ): + main() + return mock_runner + + def test_cli_overrides_frontmatter_and_env(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ASKCC_CLAUDE_EFFORT_LEVEL", "medium") + runner = self._run_main(["--effort", "max"], frontmatter_effort="low") + assert runner.run.call_args.kwargs["effort_level"] == "max" + + def test_env_overrides_frontmatter(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ASKCC_CLAUDE_EFFORT_LEVEL", "medium") + runner = self._run_main([], frontmatter_effort="low") + assert runner.run.call_args.kwargs["effort_level"] == "medium" + + def test_frontmatter_used_when_no_cli_or_env(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("ASKCC_CLAUDE_EFFORT_LEVEL", raising=False) + runner = self._run_main([], frontmatter_effort="low") + assert runner.run.call_args.kwargs["effort_level"] == "low" + + def test_default_used_when_no_cli_env_or_frontmatter(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("ASKCC_CLAUDE_EFFORT_LEVEL", raising=False) + runner = self._run_main([], frontmatter_effort=None) + assert runner.run.call_args.kwargs["effort_level"] == DEFAULT_EFFORT_LEVEL + + +class TestRunnerFrontmatterFlags: + """Tests that ClaudeRunner translates AgentConfig frontmatter fields to CLI flags.""" + + ISSUE_URL = "https://github.com/test/repo/issues/1" + + def _config(self, **overrides) -> AgentConfig: + base = { + "action_name": "test", + "description": "test", + "system_prompt": "prompt", + "user_prompt_template": "$issue_content_file", + "system_prompt_file": "TEST_SYSTEM_PROMPT.md", + "user_prompt_file": "TEST_USER_PROMPT.md", + } + base.update(overrides) + return AgentConfig(**base) + + def _run(self, config: AgentConfig) -> list[str]: + runner = ClaudeRunner() + mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="{}", stderr="") + with patch("askcc.runners.subprocess.run", return_value=mock_result) as mock_run: + runner.run("prompt", config=config, issue_url=self.ISSUE_URL, cwd=Path.cwd()) + return mock_run.call_args[0][0] + + def test_model_flag_emitted(self): + cmd = self._run(self._config(model="opus")) + assert "--model" in cmd + assert cmd[cmd.index("--model") + 1] == "opus" + + def test_tools_flag_emitted_as_csv(self): + cmd = self._run(self._config(tools=("Read", "Write", "Bash(gh:*)"))) + assert "--allowedTools" in cmd + assert cmd[cmd.index("--allowedTools") + 1] == "Read,Write,Bash(gh:*)" + + def test_disallowed_tools_flag_emitted(self): + cmd = self._run(self._config(disallowed_tools=("WebFetch",))) + assert "--disallowedTools" in cmd + assert cmd[cmd.index("--disallowedTools") + 1] == "WebFetch" + + def test_max_turns_flag_emitted(self): + cmd = self._run(self._config(max_turns=200)) + assert "--max-turns" in cmd + assert cmd[cmd.index("--max-turns") + 1] == "200" + + def test_no_frontmatter_flags_when_unset(self): + cmd = self._run(self._config()) + for flag in ("--model", "--allowedTools", "--disallowedTools", "--max-turns"): + assert flag not in cmd diff --git a/uv.lock b/uv.lock index e07c76f..0177694 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = "==3.14.*" [[package]] name = "askcc" -version = "0.2.5" +version = "0.2.6" source = { editable = "." } [package.dev-dependencies]