feat(plan_progress): auto-detect env for blocked plans

This commit is contained in:
csh 2026-01-29 14:54:12 +08:00
parent 0d9a8ec465
commit a85453439f
3 changed files with 135 additions and 22 deletions

View File

@ -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:start -->"
PLAN_STATUS_END = "<!-- plan-status:end -->"
PLAN_FILE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})-.+\.md$")
PLAN_LINE_RE = re.compile(
r"^- \[(?P<check>[ xX])\] `(?P<plan>[^`]+)` (?P<status>done|blocked|pending)(?:: (?P<note>.*))?$"
r"^- \[(?P<check>[ xX])\] `(?P<plan>[^`]+)` (?P<status>done|blocked|pending|in-progress|skipped)(?:: (?P<note>.*))?$"
)
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,8 +180,17 @@ 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)

View File

@ -78,28 +78,63 @@
| `执行主循环`、`继续执行`、`下一个 Plan` | 常规模式 | 遇确认场景可询问用户 |
| `自动执行所有 Plan` | 无交互模式 | 不询问,按规则自动处理 |
0. 选择 Plan
**Plan 状态**
| 状态 | 含义 |
| ----------- | ------------------------- |
| pending | 待执行 |
| in-progress | 执行中(崩溃恢复用) |
| done | 已完成 |
| blocked | 阻塞(需人工介入) |
| skipped | 跳过Plan 不再需要执行) |
> 说明:`skipped` 仅用于永久不再执行;如需恢复执行,需手动改回 `pending`
**环境阻塞格式**`blocked: env:<环境>:<Task列表>`
- 示例:`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 <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 <plan> -status done -progress memory-bank/progress.md`
- 阻塞:`python {{PLAYBOOK_SCRIPTS}}/plan_progress.py record -plan <plan> -status blocked -progress memory-bank/progress.md -note <原因>`
- 回到步骤 0 继续下一个 Plan
4. 汇总报告(所有 Plan 处理完毕后):
- 列出已完成的 Plan
- 列出阻塞的 Plan 及原因
- 列出待确认的歧义/风险/决策点
6. 记录结果:
- 全部完成:`... -status done ...`
- 有 Task 因环境跳过:`... -status blocked ... -note "env:<所需环境>:<Task列表>"`
- 其他阻塞:`... -status blocked ... -note "<原因>"`
- 跳过整个 Plan`... -status skipped ... -note "<原因>"`
- 回到步骤 2 继续下一个 Plan
7. 汇总报告(所有 Plan 处理完毕后):
- 已完成的 Plan
- 阻塞/跳过的 Plan 及原因
- 需要在其他环境执行的 Plan`blocked: env:...`
- 待确认的歧义/风险/决策点
- 如需记录重要决策,写入 `memory-bank/decisions.md`
8. **结束**:主循环终止
## Plan 规则

View File

@ -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 状态",
"",
"<!-- plan-status:start -->",
f"- [ ] `2026-01-05-env.md` blocked: env:{env}:Task1",
"<!-- plan-status:end -->",
"",
]
),
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)