diff --git a/aide-program/aide/__main__.py b/aide-program/aide/__main__.py index dde501f..e551ad4 100644 --- a/aide-program/aide/__main__.py +++ b/aide-program/aide/__main__.py @@ -1,5 +1,7 @@ +import sys + from aide.main import main if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/aide-program/aide/flow/__init__.py b/aide-program/aide/flow/__init__.py new file mode 100644 index 0000000..db76a88 --- /dev/null +++ b/aide-program/aide/flow/__init__.py @@ -0,0 +1,2 @@ +"""aide flow:进度追踪与 Git 集成。""" + diff --git a/aide-program/aide/flow/errors.py b/aide-program/aide/flow/errors.py new file mode 100644 index 0000000..35efd77 --- /dev/null +++ b/aide-program/aide/flow/errors.py @@ -0,0 +1,8 @@ +"""错误类型:用于将内部失败统一映射为 CLI 输出。""" + +from __future__ import annotations + + +class FlowError(RuntimeError): + pass + diff --git a/aide-program/aide/flow/git.py b/aide-program/aide/flow/git.py new file mode 100644 index 0000000..d5de791 --- /dev/null +++ b/aide-program/aide/flow/git.py @@ -0,0 +1,78 @@ +"""Git 操作封装:add、commit、查询提交变更文件。""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +from aide.flow.errors import FlowError + + +class GitIntegration: + def __init__(self, root: Path): + self.root = root + + def ensure_available(self) -> None: + if shutil.which("git") is None: + raise FlowError("未找到 git 命令,请先安装 git") + + def ensure_repo(self) -> None: + self.ensure_available() + result = self._run(["rev-parse", "--is-inside-work-tree"], check=False) + if result.returncode != 0 or "true" not in (result.stdout or ""): + raise FlowError("当前目录不是 git 仓库,请先执行 git init 或切换到正确目录") + + def add_all(self) -> None: + self.ensure_repo() + result = self._run(["add", "."], check=False) + if result.returncode != 0: + raise FlowError(_format_git_error("git add 失败", result)) + + def commit(self, message: str) -> str | None: + self.ensure_repo() + diff = self._run(["diff", "--cached", "--quiet"], check=False) + if diff.returncode == 0: + return None + if diff.returncode != 1: + raise FlowError(_format_git_error("git diff 失败", diff)) + + result = self._run(["commit", "-m", message], check=False) + if result.returncode != 0: + raise FlowError(_format_git_error("git commit 失败", result)) + return self.rev_parse_head() + + def rev_parse_head(self) -> str: + result = self._run(["rev-parse", "HEAD"], check=False) + if result.returncode != 0: + raise FlowError(_format_git_error("获取 commit hash 失败", result)) + return (result.stdout or "").strip() + + def status_porcelain(self, path: str) -> str: + result = self._run(["status", "--porcelain", "--", path], check=False) + if result.returncode != 0: + raise FlowError(_format_git_error("git status 失败", result)) + return result.stdout or "" + + def commit_touches_path(self, commit_hash: str, path: str) -> bool: + result = self._run(["show", "--name-only", "--pretty=format:", commit_hash], check=False) + if result.returncode != 0: + raise FlowError(_format_git_error(f"读取提交内容失败: {commit_hash}", result)) + files = [line.strip() for line in (result.stdout or "").splitlines() if line.strip()] + return path in files + + def _run(self, args: list[str], check: bool) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + cwd=self.root, + text=True, + capture_output=True, + check=check, + ) + + +def _format_git_error(prefix: str, result: subprocess.CompletedProcess[str]) -> str: + detail = (result.stderr or "").strip() or (result.stdout or "").strip() + if not detail: + return prefix + return f"{prefix}: {detail}" diff --git a/aide-program/aide/flow/hooks.py b/aide-program/aide/flow/hooks.py new file mode 100644 index 0000000..1d0107d --- /dev/null +++ b/aide-program/aide/flow/hooks.py @@ -0,0 +1,85 @@ +"""环节钩子:PlantUML 与 CHANGELOG 校验。""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +from aide.core import output +from aide.flow.errors import FlowError +from aide.flow.git import GitIntegration +from aide.flow.types import FlowStatus + + +def run_pre_commit_hooks( + *, + root: Path, + git: GitIntegration, + status: FlowStatus | None, + from_phase: str | None, + to_phase: str, + action: str, +) -> None: + if from_phase == "flow-design" and action in {"next-part", "back-part"}: + _hook_plantuml(root=root) + if from_phase == "docs" and action in {"next-part", "back-part"}: + _hook_changelog_on_leave_docs(root=root, git=git, status=status) + + +def run_post_commit_hooks(*, to_phase: str, action: str) -> None: + if to_phase == "docs" and action in {"start", "next-part", "back-part"}: + output.info("请更新 CHANGELOG.md") + + +def _hook_plantuml(*, root: Path) -> None: + docs_dir = root / "docs" + discuss_dir = root / "discuss" + candidates: list[Path] = [] + for base in (docs_dir, discuss_dir): + if not base.exists(): + continue + candidates.extend([p for p in base.rglob("*.puml") if p.is_file()]) + candidates.extend([p for p in base.rglob("*.plantuml") if p.is_file()]) + + if not candidates: + return + + if shutil.which("plantuml") is None: + output.warn("未找到 plantuml,已跳过 PlantUML 校验/PNG 生成") + return + + for file_path in candidates: + result = subprocess.run( + ["plantuml", "-tpng", str(file_path)], + cwd=root, + text=True, + capture_output=True, + ) + if result.returncode != 0: + detail = (result.stderr or "").strip() or (result.stdout or "").strip() + raise FlowError(f"PlantUML 处理失败: {file_path} {detail}".strip()) + + +def _hook_changelog_on_leave_docs(*, root: Path, git: GitIntegration, status: FlowStatus | None) -> None: + changelog = root / "CHANGELOG.md" + if not changelog.exists(): + raise FlowError("离开 docs 前需要更新 CHANGELOG.md(当前文件不存在)") + + git.ensure_repo() + if git.status_porcelain("CHANGELOG.md").strip(): + return + + if status is None: + raise FlowError("离开 docs 前需要更新 CHANGELOG.md(未找到流程状态)") + + for entry in status.history: + if entry.phase != "docs": + continue + if not entry.git_commit: + continue + if git.commit_touches_path(entry.git_commit, "CHANGELOG.md"): + return + + raise FlowError("离开 docs 前需要更新 CHANGELOG.md(未检测到 docs 阶段的更新记录)") + diff --git a/aide-program/aide/flow/storage.py b/aide-program/aide/flow/storage.py new file mode 100644 index 0000000..22d59c6 --- /dev/null +++ b/aide-program/aide/flow/storage.py @@ -0,0 +1,91 @@ +"""状态文件读写:锁、原子写入、归档。""" + +from __future__ import annotations + +import json +import os +import time +from contextlib import contextmanager +from pathlib import Path + +from aide.flow.errors import FlowError +from aide.flow.types import FlowStatus +from aide.flow.utils import now_task_id + + +class FlowStorage: + def __init__(self, root: Path): + self.root = root + self.aide_dir = self.root / ".aide" + self.status_path = self.aide_dir / "flow-status.json" + self.lock_path = self.aide_dir / "flow-status.lock" + self.tmp_path = self.aide_dir / "flow-status.json.tmp" + self.logs_dir = self.aide_dir / "logs" + + def ensure_ready(self) -> None: + if not self.aide_dir.exists(): + raise FlowError("未找到 .aide 目录,请先运行:aide init") + self.logs_dir.mkdir(parents=True, exist_ok=True) + + @contextmanager + def lock(self, timeout_seconds: float = 3.0, poll_seconds: float = 0.2): + self.ensure_ready() + start = time.time() + fd: int | None = None + while True: + try: + fd = os.open(str(self.lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.write(fd, str(os.getpid()).encode("utf-8")) + break + except FileExistsError: + if time.time() - start >= timeout_seconds: + raise FlowError("状态文件被占用,请稍后重试或删除 .aide/flow-status.lock") + time.sleep(poll_seconds) + try: + yield + finally: + if fd is not None: + try: + os.close(fd) + except OSError: + pass + try: + self.lock_path.unlink(missing_ok=True) + except Exception: + pass + + def load_status(self) -> FlowStatus | None: + if not self.status_path.exists(): + return None + try: + raw = self.status_path.read_text(encoding="utf-8") + data = json.loads(raw) + if not isinstance(data, dict): + raise ValueError("状态文件顶层必须为对象") + return FlowStatus.from_dict(data) + except Exception as exc: + raise FlowError(f"状态文件解析失败: {exc}") + + def save_status(self, status: FlowStatus) -> None: + payload = json.dumps(status.to_dict(), ensure_ascii=False, indent=2) + "\n" + try: + self.tmp_path.write_text(payload, encoding="utf-8") + os.replace(self.tmp_path, self.status_path) + except Exception as exc: + raise FlowError(f"写入状态文件失败: {exc}") + + def archive_existing_status(self) -> None: + if not self.status_path.exists(): + return + suffix = now_task_id() + try: + current = self.load_status() + suffix = current.task_id + except FlowError: + pass + target = self.logs_dir / f"flow-status.{suffix}.json" + try: + os.replace(self.status_path, target) + except Exception as exc: + raise FlowError(f"归档旧状态失败: {exc}") + diff --git a/aide-program/aide/flow/tracker.py b/aide-program/aide/flow/tracker.py new file mode 100644 index 0000000..0596de2 --- /dev/null +++ b/aide-program/aide/flow/tracker.py @@ -0,0 +1,198 @@ +"""FlowTracker:编排一次 flow 动作(校验 → hooks → git → 落盘 → 输出)。""" + +from __future__ import annotations + +from pathlib import Path + +from aide.core import output +from aide.core.config import ConfigManager +from aide.flow.errors import FlowError +from aide.flow.git import GitIntegration +from aide.flow.hooks import run_post_commit_hooks, run_pre_commit_hooks +from aide.flow.storage import FlowStorage +from aide.flow.types import FlowStatus, HistoryEntry +from aide.flow.utils import now_iso, now_task_id, normalize_text +from aide.flow.validator import FlowValidator + +DEFAULT_PHASES = ["task-optimize", "flow-design", "impl", "verify", "docs", "finish"] + + +class FlowTracker: + def __init__(self, root: Path, cfg: ConfigManager): + self.root = root + self.cfg = cfg + self.storage = FlowStorage(root) + self.git = GitIntegration(root) + + def start(self, phase: str, summary: str) -> bool: + return self._run(action="start", to_phase=phase, text=summary) + + def next_step(self, summary: str) -> bool: + return self._run(action="next-step", to_phase=None, text=summary) + + def back_step(self, reason: str) -> bool: + return self._run(action="back-step", to_phase=None, text=reason) + + def next_part(self, phase: str, summary: str) -> bool: + return self._run(action="next-part", to_phase=phase, text=summary) + + def back_part(self, phase: str, reason: str) -> bool: + return self._run(action="back-part", to_phase=phase, text=reason) + + def issue(self, description: str) -> bool: + return self._run(action="issue", to_phase=None, text=description) + + def error(self, description: str) -> bool: + return self._run(action="error", to_phase=None, text=description) + + def _run(self, *, action: str, to_phase: str | None, text: str) -> bool: + try: + self.storage.ensure_ready() + config = self.cfg.load_config() + phases = _get_phases(config) + validator = FlowValidator(phases) + + normalized_text = normalize_text(text) + if not normalized_text: + raise FlowError("文本参数不能为空") + + with self.storage.lock(): + if action == "start": + assert to_phase is not None + validator.validate_start(to_phase) + self.storage.archive_existing_status() + status = FlowStatus( + task_id=now_task_id(), + current_phase=to_phase, + current_step=0, + started_at=now_iso(), + history=[], + ) + updated = self._apply_action( + status=status, + action=action, + from_phase=None, + to_phase=to_phase, + text=normalized_text, + validator=validator, + ) + self.storage.save_status(updated) + output.ok(f"任务开始: {to_phase}") + run_post_commit_hooks(to_phase=to_phase, action=action) + return True + + status = self.storage.load_status() + if status is None: + raise FlowError("未找到流程状态,请先运行:aide flow start <环节名> \"<总结>\"") + + current_phase = status.current_phase + validator.validate_phase_exists(current_phase) + + if action == "next-part": + assert to_phase is not None + validator.validate_next_part(current_phase, to_phase) + elif action == "back-part": + assert to_phase is not None + validator.validate_back_part(current_phase, to_phase) + else: + to_phase = current_phase + + updated = self._apply_action( + status=status, + action=action, + from_phase=current_phase, + to_phase=to_phase, + text=normalized_text, + validator=validator, + ) + self.storage.save_status(updated) + + if action == "next-part": + output.ok(f"进入环节: {to_phase}") + elif action == "back-part": + output.warn(f"回退到环节: {to_phase}") + elif action == "error": + output.err(f"错误已记录: {normalized_text}") + + run_post_commit_hooks(to_phase=to_phase, action=action) + return True + except FlowError as exc: + output.err(str(exc)) + return False + + def _apply_action( + self, + *, + status: FlowStatus, + action: str, + from_phase: str | None, + to_phase: str, + text: str, + validator: FlowValidator, + ) -> FlowStatus: + if action in {"next-part", "back-part"} and from_phase is None: + raise FlowError("内部错误:缺少 from_phase") + + if action == "next-part": + assert from_phase is not None + validator.validate_next_part(from_phase, to_phase) + elif action == "back-part": + assert from_phase is not None + validator.validate_back_part(from_phase, to_phase) + elif action == "start": + validator.validate_start(to_phase) + else: + validator.validate_phase_exists(to_phase) + + run_pre_commit_hooks( + root=self.root, + git=self.git, + status=status, + from_phase=from_phase, + to_phase=to_phase, + action=action, + ) + + message = _build_commit_message(action=action, phase=to_phase, text=text) + self.git.add_all() + commit_hash = self.git.commit(message) + + next_step = status.current_step + 1 + entry = HistoryEntry( + timestamp=now_iso(), + action=action, + phase=to_phase, + step=next_step, + summary=text, + git_commit=commit_hash, + ) + + history = [*status.history, entry] + return FlowStatus( + task_id=status.task_id, + current_phase=to_phase, + current_step=next_step, + started_at=status.started_at, + history=history, + ) + + +def _get_phases(config: dict) -> list[str]: + flow_cfg = config.get("flow", {}) + phases = flow_cfg.get("phases", DEFAULT_PHASES) + if not isinstance(phases, list) or not phases: + return DEFAULT_PHASES + return phases + + +def _build_commit_message(*, action: str, phase: str, text: str) -> str: + if action == "issue": + return f"[aide] {phase} issue: {text}" + if action == "error": + return f"[aide] {phase} error: {text}" + if action == "back-step": + return f"[aide] {phase} back-step: {text}" + if action == "back-part": + return f"[aide] {phase} back-part: {text}" + return f"[aide] {phase}: {text}" + diff --git a/aide-program/aide/flow/types.py b/aide-program/aide/flow/types.py new file mode 100644 index 0000000..087b960 --- /dev/null +++ b/aide-program/aide/flow/types.py @@ -0,0 +1,102 @@ +"""数据结构:流程状态与历史条目。""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class HistoryEntry: + timestamp: str + action: str + phase: str + step: int + summary: str + git_commit: str | None = None + + def to_dict(self) -> dict[str, Any]: + data: dict[str, Any] = { + "timestamp": self.timestamp, + "action": self.action, + "phase": self.phase, + "step": self.step, + "summary": self.summary, + } + if self.git_commit is not None: + data["git_commit"] = self.git_commit + return data + + @staticmethod + def from_dict(data: dict[str, Any]) -> "HistoryEntry": + timestamp = _require_str(data, "timestamp") + action = _require_str(data, "action") + phase = _require_str(data, "phase") + step = _require_int(data, "step") + summary = _require_str(data, "summary") + git_commit = data.get("git_commit") + if git_commit is not None and not isinstance(git_commit, str): + raise ValueError("git_commit 必须为字符串或缺失") + return HistoryEntry( + timestamp=timestamp, + action=action, + phase=phase, + step=step, + summary=summary, + git_commit=git_commit, + ) + + +@dataclass(frozen=True) +class FlowStatus: + task_id: str + current_phase: str + current_step: int + started_at: str + history: list[HistoryEntry] + + def to_dict(self) -> dict[str, Any]: + return { + "task_id": self.task_id, + "current_phase": self.current_phase, + "current_step": self.current_step, + "started_at": self.started_at, + "history": [h.to_dict() for h in self.history], + } + + @staticmethod + def from_dict(data: dict[str, Any]) -> "FlowStatus": + task_id = _require_str(data, "task_id") + current_phase = _require_str(data, "current_phase") + current_step = _require_int(data, "current_step") + started_at = _require_str(data, "started_at") + raw_history = data.get("history") + if not isinstance(raw_history, list): + raise ValueError("history 必须为列表") + history: list[HistoryEntry] = [] + for item in raw_history: + if not isinstance(item, dict): + raise ValueError("history 条目必须为对象") + history.append(HistoryEntry.from_dict(item)) + return FlowStatus( + task_id=task_id, + current_phase=current_phase, + current_step=current_step, + started_at=started_at, + history=history, + ) + + +def _require_str(data: dict[str, Any], key: str) -> str: + value = data.get(key) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{key} 必须为非空字符串") + return value + + +def _require_int(data: dict[str, Any], key: str) -> int: + value = data.get(key) + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"{key} 必须为整数") + return value + diff --git a/aide-program/aide/flow/utils.py b/aide-program/aide/flow/utils.py new file mode 100644 index 0000000..26eebc9 --- /dev/null +++ b/aide-program/aide/flow/utils.py @@ -0,0 +1,18 @@ +"""工具函数:时间戳与文本处理。""" + +from __future__ import annotations + +from datetime import datetime + + +def now_iso() -> str: + return datetime.now().astimezone().isoformat(timespec="seconds") + + +def now_task_id() -> str: + return datetime.now().astimezone().strftime("%Y-%m-%dT%H-%M-%S") + + +def normalize_text(value: str) -> str: + return value.strip() + diff --git a/aide-program/aide/flow/validator.py b/aide-program/aide/flow/validator.py new file mode 100644 index 0000000..3a7d157 --- /dev/null +++ b/aide-program/aide/flow/validator.py @@ -0,0 +1,54 @@ +"""流程校验:环节合法性与跳转规则。""" + +from __future__ import annotations + +from aide.flow.errors import FlowError + + +class FlowValidator: + def __init__(self, phases: list[str]): + self.phases = _normalize_phases(phases) + + def validate_phase_exists(self, phase: str) -> None: + if phase not in self.phases: + raise FlowError(f"未知环节: {phase}(请检查 flow.phases 配置)") + + def validate_start(self, phase: str) -> None: + self.validate_phase_exists(phase) + + def validate_next_part(self, from_phase: str, to_phase: str) -> None: + self.validate_phase_exists(from_phase) + self.validate_phase_exists(to_phase) + from_index = self.phases.index(from_phase) + to_index = self.phases.index(to_phase) + if to_index != from_index + 1: + raise FlowError( + f"非法跳转: {from_phase} -> {to_phase}(next-part 只能前进到相邻环节)" + ) + + def validate_back_part(self, from_phase: str, to_phase: str) -> None: + self.validate_phase_exists(from_phase) + self.validate_phase_exists(to_phase) + from_index = self.phases.index(from_phase) + to_index = self.phases.index(to_phase) + if to_index >= from_index: + raise FlowError( + f"非法回退: {from_phase} -> {to_phase}(back-part 只能回退到之前环节)" + ) + + +def _normalize_phases(phases: list[str]) -> list[str]: + if not isinstance(phases, list) or not phases: + raise FlowError("flow.phases 配置无效:必须为非空列表") + normalized: list[str] = [] + seen: set[str] = set() + for item in phases: + if not isinstance(item, str) or not item.strip(): + raise FlowError("flow.phases 配置无效:环节名必须为非空字符串") + name = item.strip() + if name in seen: + raise FlowError(f"flow.phases 配置无效:环节名重复 {name!r}") + seen.add(name) + normalized.append(name) + return normalized + diff --git a/aide-program/aide/main.py b/aide-program/aide/main.py index b82a81e..b00f390 100644 --- a/aide-program/aide/main.py +++ b/aide-program/aide/main.py @@ -10,6 +10,7 @@ from typing import Any from aide.core import output from aide.core.config import ConfigManager from aide.env.manager import EnvManager +from aide.flow.tracker import FlowTracker def main(argv: list[str] | None = None) -> int: @@ -91,6 +92,43 @@ def build_parser() -> argparse.ArgumentParser: set_parser.add_argument("value", help="要写入的值,支持 bool/int/float/字符串") set_parser.set_defaults(func=handle_config_set) + # aide flow + flow_parser = subparsers.add_parser("flow", help="进度追踪与 git 集成") + flow_sub = flow_parser.add_subparsers(dest="flow_command") + + flow_start = flow_sub.add_parser("start", help="开始新任务") + flow_start.add_argument("phase", help="环节名(来自 flow.phases)") + flow_start.add_argument("summary", help="本次操作的简要说明") + flow_start.set_defaults(func=handle_flow_start) + + flow_next_step = flow_sub.add_parser("next-step", help="记录步骤前进") + flow_next_step.add_argument("summary", help="本次操作的简要说明") + flow_next_step.set_defaults(func=handle_flow_next_step) + + flow_back_step = flow_sub.add_parser("back-step", help="记录步骤回退") + flow_back_step.add_argument("reason", help="回退原因") + flow_back_step.set_defaults(func=handle_flow_back_step) + + flow_next_part = flow_sub.add_parser("next-part", help="进入下一环节") + flow_next_part.add_argument("phase", help="目标环节名(相邻下一环节)") + flow_next_part.add_argument("summary", help="本次操作的简要说明") + flow_next_part.set_defaults(func=handle_flow_next_part) + + flow_back_part = flow_sub.add_parser("back-part", help="回退到之前环节") + flow_back_part.add_argument("phase", help="目标环节名(任意之前环节)") + flow_back_part.add_argument("reason", help="回退原因") + flow_back_part.set_defaults(func=handle_flow_back_part) + + flow_issue = flow_sub.add_parser("issue", help="记录一般问题(不阻塞继续)") + flow_issue.add_argument("description", help="问题描述") + flow_issue.set_defaults(func=handle_flow_issue) + + flow_error = flow_sub.add_parser("error", help="记录严重错误(需要用户关注)") + flow_error.add_argument("description", help="错误描述") + flow_error.set_defaults(func=handle_flow_error) + + flow_parser.set_defaults(func=handle_flow_help) + parser.add_argument("--version", action="version", version="aide dev") return parser @@ -190,6 +228,60 @@ def handle_config_set(args: argparse.Namespace) -> bool: return True +def handle_flow_help(args: argparse.Namespace) -> bool: + output.info("用法: aide flow ...") + return True + + +def handle_flow_start(args: argparse.Namespace) -> bool: + root = Path.cwd() + cfg = ConfigManager(root) + tracker = FlowTracker(root, cfg) + return tracker.start(args.phase, args.summary) + + +def handle_flow_next_step(args: argparse.Namespace) -> bool: + root = Path.cwd() + cfg = ConfigManager(root) + tracker = FlowTracker(root, cfg) + return tracker.next_step(args.summary) + + +def handle_flow_back_step(args: argparse.Namespace) -> bool: + root = Path.cwd() + cfg = ConfigManager(root) + tracker = FlowTracker(root, cfg) + return tracker.back_step(args.reason) + + +def handle_flow_next_part(args: argparse.Namespace) -> bool: + root = Path.cwd() + cfg = ConfigManager(root) + tracker = FlowTracker(root, cfg) + return tracker.next_part(args.phase, args.summary) + + +def handle_flow_back_part(args: argparse.Namespace) -> bool: + root = Path.cwd() + cfg = ConfigManager(root) + tracker = FlowTracker(root, cfg) + return tracker.back_part(args.phase, args.reason) + + +def handle_flow_issue(args: argparse.Namespace) -> bool: + root = Path.cwd() + cfg = ConfigManager(root) + tracker = FlowTracker(root, cfg) + return tracker.issue(args.description) + + +def handle_flow_error(args: argparse.Namespace) -> bool: + root = Path.cwd() + cfg = ConfigManager(root) + tracker = FlowTracker(root, cfg) + return tracker.error(args.description) + + def _parse_value(raw: str) -> Any: lowered = raw.lower() if lowered in {"true", "false"}: