From a85453439f65b0c0aa05a5bbece773a02216ce76 Mon Sep 17 00:00:00 2001 From: csh Date: Thu, 29 Jan 2026 14:54:12 +0800 Subject: [PATCH] :sparkles: feat(plan_progress): auto-detect env for blocked plans --- scripts/plan_progress.py | 46 ++++++++++++++++++--- templates/AGENT_RULES.template.md | 67 +++++++++++++++++++++++-------- tests/test_plan_progress_cli.py | 44 ++++++++++++++++++++ 3 files changed, 135 insertions(+), 22 deletions(-) diff --git a/scripts/plan_progress.py b/scripts/plan_progress.py index f8e3b69..46b9938 100644 --- a/scripts/plan_progress.py +++ b/scripts/plan_progress.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import platform import re import sys from pathlib import Path @@ -8,9 +9,9 @@ PLAN_STATUS_START = "" PLAN_STATUS_END = "" PLAN_FILE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})-.+\.md$") PLAN_LINE_RE = re.compile( - r"^- \[(?P[ xX])\] `(?P[^`]+)` (?Pdone|blocked|pending)(?:: (?P.*))?$" + r"^- \[(?P[ xX])\] `(?P[^`]+)` (?Pdone|blocked|pending|in-progress|skipped)(?:: (?P.*))?$" ) -VALID_STATUSES = {"done", "blocked", "pending"} +VALID_STATUSES = {"done", "blocked", "pending", "in-progress", "skipped"} def usage() -> str: @@ -22,7 +23,7 @@ def usage() -> str: "Options:\n" " -plans DIR\n" " -plan PATH\n" - " -status done|blocked|pending\n" + " -status done|blocked|pending|in-progress|skipped\n" " -progress FILE\n" " -note TEXT\n" " -h, -help Show this help.\n" @@ -64,6 +65,12 @@ def render_plan_line(plan_key: str, status: str, note: Optional[str]) -> str: suffix += f": {note}" elif status == "pending": suffix = "pending" + elif status == "in-progress": + suffix = "in-progress" + elif status == "skipped": + suffix = "skipped" + if note: + suffix += f": {note}" else: suffix = "done" return f"- [{checked}] `{plan_key}` {suffix}" @@ -125,6 +132,24 @@ def render_progress_lines(plans: list[str]) -> list[str]: return lines +ENV_BLOCKED_RE = re.compile(r"^env:([^:]+):(.+)$") + + +def parse_env_blocked_note(note: Optional[str]) -> Optional[tuple[str, str]]: + """Parse 'env:windows:Task2,Task4' format. Returns (env, tasks) or None.""" + if not note: + return None + match = ENV_BLOCKED_RE.match(note) + if match: + return match.group(1), match.group(2) + return None + + +def detect_env() -> Optional[str]: + mapping = {"windows": "windows", "linux": "linux", "darwin": "darwin"} + return mapping.get(platform.system().lower()) + + def select_plan(plans_dir: Path, progress_path: Path) -> tuple[int, str]: if not plans_dir.is_dir(): return 2, f"ERROR: plans dir not found: {plans_dir}" @@ -155,10 +180,19 @@ def select_plan(plans_dir: Path, progress_path: Path) -> tuple[int, str]: progress_path.write_text("\n".join(lines) + "\n", encoding="utf-8") entries = parse_entries(lines, start_idx, end_idx) - for plan_key, status, _, _ in entries: - if status == "pending": + for plan_key, status, note, _ in entries: + if status in ("pending", "in-progress"): return 0, (plans_dir / plan_key).as_posix() + # Check for env-blocked Plans if current environment is detected + current_env = detect_env() + if current_env: + for plan_key, status, note, _ in entries: + if status == "blocked": + env_info = parse_env_blocked_note(note) + if env_info and env_info[0] == current_env: + return 0, (plans_dir / plan_key).as_posix() + return 2, "ERROR: no pending plans" @@ -186,7 +220,7 @@ def record_status(plan: str, status: str, progress_path: Path, note: Optional[st entries = parse_entries(lines, start_idx, end_idx) rendered_note = None - if status == "blocked" and note: + if status in ("blocked", "skipped") and note: rendered_note = normalize_note(note) updated_line = render_plan_line(plan_key, status, rendered_note) diff --git a/templates/AGENT_RULES.template.md b/templates/AGENT_RULES.template.md index 1e77c87..ebac2f1 100644 --- a/templates/AGENT_RULES.template.md +++ b/templates/AGENT_RULES.template.md @@ -78,28 +78,63 @@ | `执行主循环`、`继续执行`、`下一个 Plan` | 常规模式 | 遇确认场景可询问用户 | | `自动执行所有 Plan` | 无交互模式 | 不询问,按规则自动处理 | -0. 选择 Plan: +**Plan 状态**: + +| 状态 | 含义 | +| ----------- | ------------------------- | +| pending | 待执行 | +| in-progress | 执行中(崩溃恢复用) | +| done | 已完成 | +| blocked | 阻塞(需人工介入) | +| skipped | 跳过(Plan 不再需要执行) | + +> 说明:`skipped` 仅用于永久不再执行;如需恢复执行,需手动改回 `pending`。 + +**环境阻塞格式**:`blocked: env:<环境>:` + +- 示例:`blocked: env:windows:Task2,Task4` +- 含义:需要在指定环境执行列出的 Task +- 约束:`Task` 列表使用英文逗号分隔,不要包含空格,便于解析 + +**流程**: + +1. 检测环境: + - 由 `plan_progress.py` 自动识别当前环境(`windows` / `linux` / `darwin`) +2. 选择 Plan: - 运行 `python {{PLAYBOOK_SCRIPTS}}/plan_progress.py select -plans docs/plans -progress memory-bank/progress.md` - - 如无可执行 Plan,跳到步骤 4 -1. 阅读 Plan: + - 返回第一个可执行的 Plan: + - `pending` 或 `in-progress` 的 Plan + - `blocked: env:<当前环境>:...` 的 Plan(环境匹配时恢复执行) + - 如无可执行 Plan,跳到步骤 7 + - **注意**:每次 select 会重新扫描 `docs/plans/` 目录,支持动态添加 Plan +3. 标记开始: + - 运行 `python {{PLAYBOOK_SCRIPTS}}/plan_progress.py record -plan -status in-progress -progress memory-bank/progress.md` +4. 阅读 Plan: - 理解目标、子任务与验证标准 -2. 逐步执行: - - 按顺序执行子任务 - - 每步完成后进行必要验证(测试/日志/diff) + - 如果是从 `blocked: env:...` 恢复,只执行列出的 Task +5. 逐步执行: + - 按顺序执行 Task + - 每个 Task 完成后进行必要验证(测试/日志/diff) + - **Task 失败处理**: + - 环境不匹配(`command not found`、路径不存在)→ 记录该 Task 及所需环境,**继续下一个 Task** + - 其他阻塞 → 记录原因,跳到步骤 6 标记 Plan blocked + - **安全红线**(明文密钥等)→ 立即停止,不继续后续 Plan - 遇到歧义/风险/决策点: - 常规模式:记录到回复中,可询问用户 - 无交互模式:按「需要确认的场景」规则自动处理 - - 遇到阻塞:记录原因,跳到步骤 3 标记 blocked - - **安全红线阻塞**(发现明文密钥等):立即停止,不继续后续 Plan -3. 记录结果: - - 完成:`python {{PLAYBOOK_SCRIPTS}}/plan_progress.py record -plan -status done -progress memory-bank/progress.md` - - 阻塞:`python {{PLAYBOOK_SCRIPTS}}/plan_progress.py record -plan -status blocked -progress memory-bank/progress.md -note <原因>` - - 回到步骤 0 继续下一个 Plan -4. 汇总报告(所有 Plan 处理完毕后): - - 列出已完成的 Plan - - 列出阻塞的 Plan 及原因 - - 列出待确认的歧义/风险/决策点 +6. 记录结果: + - 全部完成:`... -status done ...` + - 有 Task 因环境跳过:`... -status blocked ... -note "env:<所需环境>:"` + - 其他阻塞:`... -status blocked ... -note "<原因>"` + - 跳过整个 Plan:`... -status skipped ... -note "<原因>"` + - 回到步骤 2 继续下一个 Plan +7. 汇总报告(所有 Plan 处理完毕后): + - 已完成的 Plan + - 阻塞/跳过的 Plan 及原因 + - 需要在其他环境执行的 Plan(`blocked: env:...`) + - 待确认的歧义/风险/决策点 - 如需记录重要决策,写入 `memory-bank/decisions.md` +8. **结束**:主循环终止 ## Plan 规则 diff --git a/tests/test_plan_progress_cli.py b/tests/test_plan_progress_cli.py index 77d11e3..7f8b538 100644 --- a/tests/test_plan_progress_cli.py +++ b/tests/test_plan_progress_cli.py @@ -2,6 +2,7 @@ import subprocess import sys import tempfile import unittest +import platform from pathlib import Path ROOT = Path(__file__).resolve().parents[1] @@ -18,6 +19,13 @@ def run_cli(*args, cwd=None): class PlanProgressCliTests(unittest.TestCase): + def _current_env(self) -> str: + system = platform.system().lower() + mapping = {"windows": "windows", "linux": "linux", "darwin": "darwin"} + if system not in mapping: + self.skipTest(f"Unsupported environment: {system}") + return mapping[system] + def test_select_seeds_progress_when_missing(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) @@ -82,6 +90,42 @@ class PlanProgressCliTests(unittest.TestCase): self.assertEqual(result.returncode, 0, msg=result.stderr) self.assertEqual(result.stdout.strip(), "docs/plans/2026-01-02-b.md") + def test_select_returns_env_blocked_plan_without_flag(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + plans_dir = root / "docs" / "plans" + plans_dir.mkdir(parents=True) + (plans_dir / "2026-01-05-env.md").write_text("env", encoding="utf-8") + + progress = root / "memory-bank" / "progress.md" + progress.parent.mkdir(parents=True) + env = self._current_env() + progress.write_text( + "\n".join( + [ + "# Plan 状态", + "", + "", + f"- [ ] `2026-01-05-env.md` blocked: env:{env}:Task1", + "", + "", + ] + ), + encoding="utf-8", + ) + + result = run_cli( + "select", + "-plans", + "docs/plans", + "-progress", + "memory-bank/progress.md", + cwd=root, + ) + + self.assertEqual(result.returncode, 0, msg=result.stderr) + self.assertEqual(result.stdout.strip(), "docs/plans/2026-01-05-env.md") + def test_record_updates_line(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir)