diff --git a/scripts/main_loop.py b/scripts/main_loop.py index a2fd8ad4..084e4dc4 100644 --- a/scripts/main_loop.py +++ b/scripts/main_loop.py @@ -31,7 +31,14 @@ PLAN_LINE_RE = re.compile( ) FINISH_STATUSES = {"done", "blocked", "skipped"} ENV_BLOCKED_RE = re.compile(r"^env:([^:]+):(.+)$") -WORKFLOW_PHASES = {"brainstorming", "planning", "executing", "done", "blocked"} +WORKFLOW_PHASES = { + "brainstorming", + "planning", + "executing", + "done", + "blocked", + "skipped", +} THREAD_LOCKS: dict[str, threading.Lock] = {} THREAD_LOCKS_GUARD = threading.Lock() @@ -51,7 +58,7 @@ def usage() -> str: " -plan PATH\n" " -status done|blocked|skipped\n" " -progress FILE\n" - " -phase brainstorming|planning|executing|done|blocked\n" + " -phase brainstorming|planning|executing|done|blocked|skipped\n" " -spec PATH\n" " -executor NAME\n" " -constraints CSV\n" @@ -365,13 +372,19 @@ def update_workflow_state( def ensure_all_plans_present( - lines: list[str], start_idx: int, end_idx: int, progress_path: Path, plan_keys: list[str] + lines: list[str], + start_idx: int, + end_idx: int, + progress_path: Path, + plan_keys: list[str], ) -> list[tuple[str, str, Optional[str], int]]: entries = parse_entries(lines, start_idx, end_idx) existing = {plan_key for plan_key, _, _, _ in entries} missing = [plan_key for plan_key in plan_keys if plan_key not in existing] if missing: - insert_lines = [render_plan_line(plan_key, "pending", None) for plan_key in missing] + insert_lines = [ + render_plan_line(plan_key, "pending", None) for plan_key in missing + ] lines[end_idx:end_idx] = insert_lines write_progress_lines(progress_path, lines) end_idx += len(insert_lines) @@ -387,23 +400,29 @@ def filter_existing_entries( def choose_claim_entry( - entries: list[tuple[str, str, Optional[str], int]], current_env: Optional[str] + entries: list[tuple[str, str, Optional[str], int]], + current_env: Optional[str], + plan_keys: list[str], ) -> Optional[tuple[str, Optional[str], int]]: - for plan_key, status, note, idx in entries: + entry_by_plan: dict[str, tuple[str, str, Optional[str], int]] = {} + for entry in entries: + entry_by_plan.setdefault(entry[0], entry) + ordered_entries = [ + entry_by_plan[plan_key] for plan_key in plan_keys if plan_key in entry_by_plan + ] + + for plan_key, status, note, idx in ordered_entries: if status == "in-progress": return plan_key, note, idx - for plan_key, status, note, idx in entries: + for plan_key, status, note, idx in ordered_entries: if status == "pending": return plan_key, note, idx - - if current_env: - for plan_key, status, note, idx in entries: - if status != "blocked": - continue - env_info = parse_env_blocked_note(note) - if env_info and env_info[0] == current_env: - return plan_key, note, idx + if status != "blocked" or not current_env: + continue + env_info = parse_env_blocked_note(note) + if env_info and env_info[0] == current_env: + return plan_key, note, idx return None @@ -419,13 +438,17 @@ def claim_plan(plans_dir: Path, progress_path: Path) -> tuple[int, str]: with locked_progress(progress_path): lines = load_progress_lines(progress_path) try: - lines, start_idx, end_idx = ensure_plan_block(lines, progress_path, plan_keys) + lines, start_idx, end_idx = ensure_plan_block( + lines, progress_path, plan_keys + ) except ValueError as exc: return 2, f"ERROR: {exc}" - entries = ensure_all_plans_present(lines, start_idx, end_idx, progress_path, plan_keys) + entries = ensure_all_plans_present( + lines, start_idx, end_idx, progress_path, plan_keys + ) entries = filter_existing_entries(entries, plan_keys) - chosen = choose_claim_entry(entries, detect_env()) + chosen = choose_claim_entry(entries, detect_env(), plan_keys) if not chosen: return 0, "NOOP: no claimable plans" @@ -457,18 +480,24 @@ def finish_plan( lines = load_progress_lines(progress_path) try: - lines, start_idx, end_idx = ensure_plan_block(lines, progress_path, [plan_key]) + lines, start_idx, end_idx = ensure_plan_block( + lines, progress_path, [plan_key] + ) except ValueError as exc: return 2, f"ERROR: {exc}" entries = parse_entries(lines, start_idx, end_idx) rendered_note = normalize_note(note) if note else None updated_line = render_plan_line(plan_key, status, rendered_note) + workflow_phase = { + "done": "done", + "blocked": "blocked", + "skipped": "skipped", + }[status] for entry_plan, _, _, idx in entries: if entry_plan == plan_key: lines[idx] = updated_line - workflow_phase = "done" if status == "done" else "blocked" lines = update_workflow_state( lines, phase=workflow_phase, @@ -478,7 +507,6 @@ def finish_plan( return 0, updated_line lines[end_idx:end_idx] = [updated_line] - workflow_phase = "done" if status == "done" else "blocked" lines = update_workflow_state( lines, phase=workflow_phase, diff --git a/templates/AGENT_RULES.template.md b/templates/AGENT_RULES.template.md index f80a0c68..5044a910 100644 --- a/templates/AGENT_RULES.template.md +++ b/templates/AGENT_RULES.template.md @@ -83,7 +83,7 @@ - `$subagent-driven-development` 仅在 Plan 或平台明确要求时使用, 不是默认执行器 - 执行完成后,必须先运行 `main_loop.py finish` 写回状态, - 再更新 `progress.md` 上半部分摘要 + 再更新 `progress.md` 上半部分摘要,并按主循环收尾要求提交 ### Plan 要求 @@ -116,7 +116,8 @@ - `in-progress`:执行中,用于恢复中断任务 - `done`:已完成 - `blocked`:阻塞,需人工介入或切换环境 -- `skipped`:永久跳过,不再执行 +- `skipped`:永久跳过,不再执行,`workflow-state.phase` 也写为 + `skipped` `skipped` 如需恢复,必须手动改回 `pending`。 @@ -139,8 +140,9 @@ python {{PLAYBOOK_SCRIPTS}}/main_loop.py claim \ 该命令会在锁保护下串行完成三件事: - 自动识别当前环境:`windows`、`linux`、`darwin` -- 按顺序选择可执行 Plan: - `in-progress` > `pending` > `blocked: env:<当前环境>:...` +- 已有 `in-progress` 优先恢复 +- 如无 `in-progress`,按 Plan 文件顺序选择第一个可执行 Plan: + `pending` 或 `blocked: env:<当前环境>:...` - 将选中的 Plan 写成 `in-progress` 这里的锁保护的是 `progress.md` 状态块更新,避免多个 session @@ -218,10 +220,27 @@ python {{PLAYBOOK_SCRIPTS}}/main_loop.py finish \ 8. 触碰安全红线时立即停止,不继续后续 Plan 9. 常规模式下可对高风险事项向用户确认;无交互模式按本文件 的“需要确认的场景”自动处理 -10. 每次 `claim` 只领取一个 Plan;写回后再领取下一个 +10. 每次 `claim` 只领取一个 Plan;写回并完成必要提交后再领取下一个 11. 全部 Plan 处理完后,统一汇总完成项、阻塞项、跳过项、 环境需求与待确认事项 +### Plan 完成提交契约 + +- Plan `done` 后必须完成一次 commit,然后才能继续领取下一个 Plan +- 提交顺序: + 1. 完成 Plan 约定验证 + 2. 运行 `main_loop.py finish -status done` 写回状态 + 3. 必要时更新 `progress.md` 上半部分摘要和相关 memory + 4. 检查 `git status --short` 与 diff + 5. 只提交当前 Plan 相关改动 +- 不得由 `main_loop.py finish` 自动执行 `git commit` +- 不得把用户已有改动或其他 Plan 的改动混入当前 Plan commit +- 如 Plan `done` 后没有 diff,必须在回复中说明无提交原因 +- `blocked` / `skipped` 不默认提交代码改动;只有状态留痕或已验证的 + 局部成果需要保留时才提交 +- 工作区保持干净后再领取下一个 Plan;如存在不属于当前 Plan 的脏改动, + 常规模式先向用户确认,无交互模式写入风险并停止继续领取 + ## 通用执行约束 ### 代码与配置修改 @@ -297,6 +316,7 @@ python {{PLAYBOOK_SCRIPTS}}/main_loop.py finish \ - [ ] 换行符与文件格式正确 - [ ] 无语法错误或明显运行时错误 - [ ] 已通过 `main_loop.py finish` 写回 Plan 状态 +- [ ] Plan `done` 后已完成对应 commit,或已说明无 diff 无需提交 --- diff --git a/templates/prompts/coding/close-task.template.md b/templates/prompts/coding/close-task.template.md index 9895e694..84aac65e 100644 --- a/templates/prompts/coding/close-task.template.md +++ b/templates/prompts/coding/close-task.template.md @@ -17,7 +17,8 @@ ## 规则 -- 如果任务状态变更,优先通过 `main_loop.py finish` 留痕 +- 如本轮来自 `main_loop.py claim` 且任务状态变更,优先通过 + `main_loop.py finish` 留痕 - 未验证内容必须显式说明 - 只写对下一轮仍重要的信息 - 不手工改写 `workflow-state` 或 `plan-status` 状态块 @@ -26,15 +27,18 @@ 1. 核对已完成项与未完成项 2. 核对已运行验证与未运行验证 -3. 核对 `main_loop.py finish` 是否已经写回 `plan-status` -4. 核对 `workflow-state.phase` 是否与当前结果一致 +3. 如本轮来自 `main_loop.py claim`,核对 `main_loop.py finish` + 是否已经写回 `plan-status` +4. 如本轮来自 `main_loop.py claim`,核对 `workflow-state.phase` + 是否与当前结果一致 5. 如需回写上下文,更新 `active-context`、`progress` 上半部分和 `decisions` 6. 输出本轮摘要与下一步 ## 状态留痕复核 -- `main_loop.py finish` 是否已经写回 `plan-status` -- `workflow-state.phase` 是否与当前结果一致 +- 如本轮来自 `main_loop.py claim`,`main_loop.py finish` 是否已经写回 + `plan-status` +- 如本轮来自 `main_loop.py claim`,`workflow-state.phase` 是否与当前结果一致 - 如为代码类执行,`workflow-state` 中是否保留了 `executor=executing-plans` 与既定 `constraints` @@ -59,7 +63,7 @@ ## 停止条件 -- 如状态未写回,先完成留痕再收尾 +- 如已领取 Plan 但状态未写回,先完成留痕再收尾 - 如验证不足以支持交付,停止并标记风险 --- diff --git a/templates/prompts/coding/verify-change.template.md b/templates/prompts/coding/verify-change.template.md index 96333222..2c8806cf 100644 --- a/templates/prompts/coding/verify-change.template.md +++ b/templates/prompts/coding/verify-change.template.md @@ -31,7 +31,8 @@ 2. 运行与本次改动直接相关的验证命令 3. 记录命令、结果和关键输出 4. 复核 diff 是否只包含预期修改 -5. 复核 `workflow-state.phase`、`plan-status` 与当前声明一致 +5. 如本轮来自 `main_loop.py claim`,复核 `workflow-state.phase`、 + `plan-status` 与当前声明一致 6. 汇总未覆盖项和剩余风险 ## 输出协议 @@ -52,8 +53,9 @@ ## 状态留痕复核 -- `workflow-state.phase` 是否与当前声明一致 -- `plan-status` 是否已经通过 `main_loop.py finish` 写回 +- 如本轮来自 `main_loop.py claim`,`workflow-state.phase` 是否与当前声明一致 +- 如本轮来自 `main_loop.py claim`,`plan-status` 是否已经通过 + `main_loop.py finish` 写回 - 如为代码类任务,`workflow-state` 中是否保留: `executor=executing-plans` `constraints=karpathy-guidelines,.agents,AGENT_RULES` diff --git a/tests/test_main_loop_cli.py b/tests/test_main_loop_cli.py index 8c9e9192..0a27559d 100644 --- a/tests/test_main_loop_cli.py +++ b/tests/test_main_loop_cli.py @@ -245,6 +245,55 @@ class MainLoopCliTests(unittest.TestCase): text = progress.read_text(encoding="utf-8") self.assertIn(f"`2026-01-05-env.md` in-progress: {note}", text) + def test_claim_prefers_earlier_env_blocked_plan_over_later_pending_plan(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + plans_dir = root / "docs" / "superpowers" / "plans" + plans_dir.mkdir(parents=True) + (plans_dir / "2026-01-01-env.md").write_text("env", encoding="utf-8") + (plans_dir / "2026-01-02-pending.md").write_text( + "pending", encoding="utf-8" + ) + + progress = root / "memory-bank" / "progress.md" + progress.parent.mkdir(parents=True) + env = self._current_env() + note = f"env:{env}:Task2" + progress.write_text( + "\n".join( + [ + "# Plan 状态", + "", + "", + f"- [ ] `2026-01-01-env.md` blocked: {note}", + "- [ ] `2026-01-02-pending.md` pending", + "", + "", + ] + ), + encoding="utf-8", + ) + + result = run_cli( + "claim", + "-plans", + "docs/superpowers/plans", + "-progress", + "memory-bank/progress.md", + cwd=root, + ) + + self.assertEqual(result.returncode, 0, msg=result.stderr) + self.assertEqual( + result.stdout.strip(), + "\n".join( + [ + "PLAN=docs/superpowers/plans/2026-01-01-env.md", + f"NOTE={note}", + ] + ), + ) + def test_finish_updates_line(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) @@ -329,13 +378,62 @@ class MainLoopCliTests(unittest.TestCase): self.assertEqual(result.returncode, 0, msg=result.stderr) text = progress.read_text(encoding="utf-8") self.assertIn("phase: done", text) - self.assertIn("spec: docs/superpowers/specs/2026-05-18-demo-design.md", text) + self.assertIn( + "spec: docs/superpowers/specs/2026-05-18-demo-design.md", text + ) self.assertIn("executor: executing-plans", text) self.assertIn( "constraints: karpathy-guidelines,.agents,AGENT_RULES", text, ) + def test_finish_skipped_updates_workflow_phase_to_skipped(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + progress = root / "memory-bank" / "progress.md" + progress.parent.mkdir(parents=True) + progress.write_text( + "\n".join( + [ + "# 当前进展", + "", + "## Workflow State", + "", + "", + "phase: executing", + "plan: docs/superpowers/plans/2026-05-18-demo.md", + "", + "", + "## Plan Status", + "", + "", + "- [ ] `2026-05-18-demo.md` in-progress", + "", + "", + ] + ) + + "\n", + encoding="utf-8", + ) + + result = run_cli( + "finish", + "-plan", + "docs/superpowers/plans/2026-05-18-demo.md", + "-status", + "skipped", + "-progress", + "memory-bank/progress.md", + "-note", + "obsolete", + cwd=root, + ) + + self.assertEqual(result.returncode, 0, msg=result.stderr) + text = progress.read_text(encoding="utf-8") + self.assertIn("phase: skipped", text) + self.assertIn("- [ ] `2026-05-18-demo.md` skipped: obsolete", text) + def test_record_updates_workflow_state_block(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) @@ -378,7 +476,9 @@ class MainLoopCliTests(unittest.TestCase): text = progress.read_text(encoding="utf-8") self.assertIn("", text) self.assertIn("phase: planning", text) - self.assertIn("spec: docs/superpowers/specs/2026-05-18-demo-design.md", text) + self.assertIn( + "spec: docs/superpowers/specs/2026-05-18-demo-design.md", text + ) self.assertIn("plan: docs/superpowers/plans/2026-05-18-demo.md", text) self.assertIn("executor: executing-plans", text) self.assertIn( @@ -450,7 +550,9 @@ class MainLoopCliTests(unittest.TestCase): text = progress.read_text(encoding="utf-8") self.assertIn("phase: done", text) - self.assertIn("spec: docs/superpowers/specs/2026-05-18-demo-design.md", text) + self.assertIn( + "spec: docs/superpowers/specs/2026-05-18-demo-design.md", text + ) self.assertIn("plan: docs/superpowers/plans/2026-05-18-demo.md", text) self.assertIn("executor: executing-plans", text) self.assertIn( @@ -513,7 +615,9 @@ class MainLoopCliTests(unittest.TestCase): text = progress.read_text(encoding="utf-8") self.assertIn("phase: planning", text) - self.assertIn("spec: docs/superpowers/specs/2026-05-18-demo-design.md", text) + self.assertIn( + "spec: docs/superpowers/specs/2026-05-18-demo-design.md", text + ) self.assertIn("plan: docs/superpowers/plans/2026-05-18-demo.md", text) self.assertIn("executor: executing-plans", text) self.assertIn( @@ -571,7 +675,9 @@ class MainLoopCliTests(unittest.TestCase): text = progress.read_text(encoding="utf-8") self.assertIn("phase: planning", text) - self.assertIn("spec: docs/superpowers/specs/2026-05-18-demo-design.md", text) + self.assertIn( + "spec: docs/superpowers/specs/2026-05-18-demo-design.md", text + ) self.assertIn("plan: docs/superpowers/plans/2026-05-18-demo.md", text) self.assertIn("executor: executing-plans", text) self.assertIn( diff --git a/tests/test_sync_templates_placeholders.py b/tests/test_sync_templates_placeholders.py index 895b4868..15e7512c 100644 --- a/tests/test_sync_templates_placeholders.py +++ b/tests/test_sync_templates_placeholders.py @@ -76,6 +76,7 @@ class SyncTemplatesPlaceholdersTests(unittest.TestCase): ROOT / "templates" / "prompts" / "coding" / "close-task.template.md" ).read_text(encoding="utf-8") self.assertIn("main_loop.py finish", close_task_template) + self.assertIn("如本轮来自 `main_loop.py claim`", close_task_template) self.assertIn("workflow-state.phase", close_task_template) self.assertIn("## Completed", close_task_template) self.assertIn("## Not Completed", close_task_template) @@ -86,6 +87,7 @@ class SyncTemplatesPlaceholdersTests(unittest.TestCase): verify_change_template = ( ROOT / "templates" / "prompts" / "coding" / "verify-change.template.md" ).read_text(encoding="utf-8") + self.assertIn("如本轮来自 `main_loop.py claim`", verify_change_template) self.assertIn("workflow-state.phase", verify_change_template) self.assertIn("plan-status", verify_change_template) self.assertIn("## Validated", verify_change_template) @@ -108,9 +110,9 @@ class SyncTemplatesPlaceholdersTests(unittest.TestCase): self.assertIn("## Open Questions", code_review_template) self.assertIn("## Residual Risk", code_review_template) - prompts_readme = ( - ROOT / "templates" / "prompts" / "README.md" - ).read_text(encoding="utf-8") + prompts_readme = (ROOT / "templates" / "prompts" / "README.md").read_text( + encoding="utf-8" + ) self.assertIn("AGENT_RULES.md", prompts_readme) self.assertIn("docs/superpowers/", prompts_readme) self.assertIn("不是流程权威", prompts_readme) @@ -132,16 +134,27 @@ class SyncTemplatesPlaceholdersTests(unittest.TestCase): self.assertNotIn("main_loop.py claim", agent_behavior_template) self.assertNotIn("playbook.py -record-spec", agent_behavior_template) self.assertNotIn("playbook.py -record-plan", agent_behavior_template) - self.assertIn("项目上下文与执行状态写入 `memory-bank/`", agent_behavior_template) + self.assertIn( + "项目上下文与执行状态写入 `memory-bank/`", agent_behavior_template + ) - rules_template = ( - ROOT / "templates" / "AGENT_RULES.template.md" - ).read_text(encoding="utf-8") + rules_template = (ROOT / "templates" / "AGENT_RULES.template.md").read_text( + encoding="utf-8" + ) self.assertIn("唯一流程约束中心", rules_template) self.assertIn("唯一设计与计划产物中心", rules_template) self.assertIn("memory-bank/progress.md", rules_template) + self.assertIn("已有 `in-progress` 优先恢复", rules_template) + self.assertIn("按 Plan 文件顺序选择第一个可执行 Plan", rules_template) + self.assertIn("Plan `done` 后必须完成一次 commit", rules_template) + self.assertIn( + "不得由 `main_loop.py finish` 自动执行 `git commit`", rules_template + ) + self.assertIn("工作区保持干净后再领取下一个 Plan", rules_template) - def test_sync_templates_replaces_playbook_scripts_without_main_language_support(self): + def test_sync_templates_replaces_playbook_scripts_without_main_language_support( + self, + ): with tempfile.TemporaryDirectory() as tmp_dir: config_body = f""" [playbook]