diff --git a/agent-ops/.version b/agent-ops/.version index b6bb0473..166971bb 100644 --- a/agent-ops/.version +++ b/agent-ops/.version @@ -1 +1 @@ -1.1.185 +1.1.186 diff --git a/agent-ops/skills/common/prepare-epic-work-items/SKILL.md b/agent-ops/skills/common/prepare-epic-work-items/SKILL.md index a5a79313..414ced70 100644 --- a/agent-ops/skills/common/prepare-epic-work-items/SKILL.md +++ b/agent-ops/skills/common/prepare-epic-work-items/SKILL.md @@ -28,6 +28,7 @@ description: 현재 또는 지정 Milestone의 정확히 한 Epic을 작은 직 - 실행 identity는 `:`다. - standalone 사이클의 다음 Epic은 현재 Epic의 모든 Task가 workstate sync에서 완료된 `EPIC_COMPLETED` 뒤에 시작한다. - 상위 coordinator가 고정한 batch에서는 현재 Epic의 `EPIC_WORK_ITEMS_READY`도 다음 선택 Epic 준비를 허용한다. 이때 현재 Epic pair는 유지하고, 다음 Epic cycle은 batch Task id 합집합 안의 앞선 pair를 구조 검증하되 소유하거나 변경하지 않는다. +- 실행 중 Epic cycle의 batch Task id 합집합은 바꾸지 않는다. 준비 terminal 뒤에는 같은 Epic을 이후 단독/복수 batch의 일부로 다시 검증할 수 있다. - `EPIC_WORK_ITEMS_READY`는 큰 작업 plan이 준비됐다는 뜻이며 구현 완료가 아니다. - 개별 `EPIC_WORK_ITEMS_READY`는 dispatcher 시작 신호가 아니다. 복수 선택의 dispatcher gate는 상위 coordinator의 `MILESTONE_WORK_ITEMS_READY` 하나다. - 같은 identity를 다시 실행하면 active pair, USER_REVIEW, runtime state를 먼저 대조하고 중복 plan을 만들지 않는다. diff --git a/agent-ops/skills/common/prepare-epic-work-items/scripts/run_epic_cycle.py b/agent-ops/skills/common/prepare-epic-work-items/scripts/run_epic_cycle.py index 6213895a..8c95d9d8 100755 --- a/agent-ops/skills/common/prepare-epic-work-items/scripts/run_epic_cycle.py +++ b/agent-ops/skills/common/prepare-epic-work-items/scripts/run_epic_cycle.py @@ -660,6 +660,7 @@ def cycle(args: argparse.Namespace) -> int: raise CycleError("target Epic Task ids changed after cycle scope was fixed") if ( state + and state.get("status") != "completed" and state.get("batch_task_ids") is not None and state.get("batch_task_ids") != sorted(allowed_task_ids) ): diff --git a/agent-ops/skills/common/prepare-epic-work-items/tests/test_run_epic_cycle.py b/agent-ops/skills/common/prepare-epic-work-items/tests/test_run_epic_cycle.py index 849e411a..c30c8c6d 100644 --- a/agent-ops/skills/common/prepare-epic-work-items/tests/test_run_epic_cycle.py +++ b/agent-ops/skills/common/prepare-epic-work-items/tests/test_run_epic_cycle.py @@ -294,6 +294,22 @@ class EpicCycleContractTest(unittest.TestCase): state = MODULE.read_state(state_path) self.assertEqual(state["event"], "EPIC_WORK_ITEMS_READY") + reused_in_larger_batch = MODULE.main( + [ + "--workspace", + str(workspace), + "--milestone", + str(milestone.relative_to(workspace)), + "--epic", + "sample-epic", + "--planner-agent", + "codex", + "--batch-task-ids", + "large-task,later-task", + ] + ) + self.assertEqual(reused_in_larger_batch, 0) + task_root = workspace / "agent-task" / "m-sample-milestone" for path in task_root.iterdir(): path.unlink() diff --git a/agent-ops/skills/common/prepare-milestone-workspace/SKILL.md b/agent-ops/skills/common/prepare-milestone-workspace/SKILL.md index 3d195ea9..1591d600 100644 --- a/agent-ops/skills/common/prepare-milestone-workspace/SKILL.md +++ b/agent-ops/skills/common/prepare-milestone-workspace/SKILL.md @@ -1,20 +1,20 @@ --- name: prepare-milestone-workspace -description: 명시된 절대 또는 repository-root-relative workspace에 계획 상태의 Milestone을 동기화하고 Git Flow feature branch/worktree를 준비한 뒤, 선택한 한 개 또는 여러 Epic을 검토된 작업으로 변환하고 전체 준비 배리어 뒤 dispatcher를 시작할 때 사용한다. "../iop-s1 위치에 X 작업 준비해", "X에 Y 작업준비해. Epic은 두 번째까지 진행해" 요청에서 사용한다. +description: 계획 상태의 Milestone을 명시 workspace의 Git Flow feature worktree로 준비하거나, 이미 준비된 현재 feature workspace에서 선택한 한 개 또는 여러 Epic을 검토된 작업으로 변환하고 전체 준비 배리어 뒤 dispatcher를 시작할 때 사용한다. "../iop-s1 위치에 X 작업 준비해", "현 마일스톤에 두 번째 에픽 작업 시작해", "X 마일스톤에 1,2번째 에픽까지 작업 시작해" 요청에서 사용한다. --- # Prepare Milestone Workspace -` 작업 준비해` 요청은 workspace만 만드는 요청이 아니다. 대상 Milestone 정합성 확인부터 선택 Epic 준비와 dispatcher 전환까지 수행하는 전체 흐름으로 해석한다. +` 작업 준비해`는 workspace 생성 모드로, `현|<이름> 마일스톤에 <범위> Epic 작업 시작해`는 현재 workspace 실행 모드로 해석한다. 두 모드 모두 정합성 확인부터 선택 Epic 준비와 dispatcher 전환까지 수행한다. ## 목적 -계획 가능한 Milestone 하나를 검증된 `feature/` workspace로 전환하고, 선택 Epic 전체가 준비된 뒤에만 구현 dispatcher를 시작한다. 의미 정합성은 roadmap 스킬이, branch/worktree/current와 batch lifecycle은 번들 스크립트가 소유한다. +계획 가능한 Milestone 하나를 검증된 `feature/` workspace로 전환하거나 이미 준비된 동일 branch를 재사용하고, 선택 Epic 전체가 준비된 뒤에만 구현 dispatcher를 시작한다. 의미 정합성은 roadmap 스킬이, branch/worktree/current와 batch lifecycle은 번들 스크립트가 소유한다. ## 입력 - `target-milestone`: 활성 Milestone 이름, id, slug 또는 경로 (필수) -- `workspace`: feature worktree로 사용할 절대 경로 또는 develop repository root 기준 상대 경로 (필수) +- `workspace`: 생성 모드에서는 feature worktree 절대 경로 또는 develop repository root 기준 상대 경로가 필수다. 현재 workspace 실행 모드에서는 현재 repository root를 사용한다. - `planner-agent`: `codex`, `claude`, `gemini`, `pi` 중 하나 (생략 시 `codex`) - `review-agent`: 생략하면 `planner-agent`와 같다. (선택) - `planner-model`, `review-model`: provider별 model override. Codex 기본 사용 시 `planner-model` 생략 시 `gpt-5.6-sol`, 다른 provider는 해당 CLI 기본 모델을 사용한다. (선택) @@ -23,12 +23,13 @@ description: 명시된 절대 또는 repository-root-relative workspace에 계 - `target-epics`: `first-incomplete`, 정확한 Epic id/title의 comma list, 또는 문서 순서의 1-based inclusive range `N..M`. 생략하면 `first-incomplete`를 사용한다. (선택) - `retry`: 기록된 attention/recovery 조건을 사용자가 해소한 뒤 batch를 재개할 때만 사용한다. (선택) -트리거의 첫 번째 위치 표현(``)은 workspace로, 두 번째 표현(``)은 대상 Milestone으로 각각 확정한다. 상대 workspace는 develop repository root 기준으로 해석한다. `두 번째 Epic까지`는 `1..2`, `세 번째부터 네 번째 Epic까지`는 `3..4`로 변환한다. workspace가 생략되거나 둘 이상의 경로로 해석되면 임의로 `current`나 기본 경로를 사용하지 않고 사용자에게 확인한다. +생성 모드의 첫 번째 위치 표현(``)은 workspace로, 두 번째 표현(``)은 대상 Milestone으로 각각 확정한다. 상대 workspace는 develop repository root 기준으로 해석한다. 현재 workspace 실행 모드의 `현 마일스톤`은 `current.md`와 현재 feature branch가 함께 가리키는 Milestone으로, 이름을 지정하면 같은 workspace의 branch/current와 정확히 일치해야 한다. `두 번째 Epic`은 `2..2`, `두 번째 Epic까지`와 `1,2번째 Epic까지`는 `1..2`, `세 번째부터 네 번째 Epic까지`는 `3..4`로 변환한다. ## 사전 조건 -- 대상 checkout은 Git Flow develop branch이고 tracked/untracked 변경이 없어야 한다. -- 대상 Milestone은 정확히 `[계획]`, `구현 잠금: 해제`, `결정 필요: 없음`이어야 한다. +- 생성 모드는 clean Git Flow develop checkout과 정확히 `[계획]`인 Milestone을 요구한다. +- 현재 workspace 실행 모드는 target slug와 일치하는 `feature/` branch, 일치하는 local `current.md`, clean/upstream-synced workspace를 요구하고 Milestone `[계획]` 또는 `[진행중]`을 허용한다. 기록된 active batch 재개만 상태 소유 변경을 허용한다. +- 두 모드 모두 `구현 잠금: 해제`, `결정 필요: 없음`이어야 한다. - `sync-milestone-workstate mode=consistency-check`가 `ready`여야 한다. - remote와 `gitflow.branch.develop`, `gitflow.prefix.feature`를 확인할 수 있어야 한다. - 선택 agent의 비대화식 one-shot capability probe가 branch 생성 전에 성공해야 한다. @@ -41,8 +42,8 @@ description: 명시된 절대 또는 repository-root-relative workspace에 계 - 기계적으로 고칠 수 있는 drift는 보고된 owner 스킬로 갱신하고 commit/push한 뒤 consistency check를 다시 실행한다. - 사용자만 결정할 수 있는 drift는 `roadmap-sdd mode=review-ready`의 `agent-roadmap/sdd///USER_REVIEW.md`로 남기고 commit/push한 뒤 `USER_REVIEW`로 멈춘다. pre-plan 문제에 `agent-task/**/USER_REVIEW.md`를 만들지 않는다. -2. **workspace를 준비한다** - - 아래 스크립트를 foreground로 한 번 실행한다. 실행 중 caller LLM이 timer polling, `ps`, state 파일 검사 또는 중복 실행을 하지 않는다. +2. **실행 모드를 선택한다** + - workspace 위치를 명시한 준비 요청은 생성 모드로 실행한다. ```bash python3 agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_workspace.py \ @@ -58,6 +59,18 @@ python3 agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_work - branch는 Milestone id가 아니라 파일 basename을 사용한 `feature/`다. - 기존 branch/worktree는 정확히 같은 branch·경로이고 clean할 때만 재개한다. - remote branch 생성 뒤 후속 단계가 실패해도 branch/worktree를 자동 삭제하지 않는다. + - `Epic 작업 시작해` 요청은 현재 workspace 실행 모드로 foreground 실행한다. + +```bash +python3 agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_workspace.py \ + --existing-workspace \ + --workspace "$CURRENT_WORKSPACE" \ + --milestone "$MILESTONE" \ + --epics "$EPICS" +``` + + - 현재 workspace가 target feature branch/current와 다르면 다른 worktree를 탐색하거나 branch를 바꾸지 않고 `FAILED`로 멈춘다. + - 두 모드 모두 실행 중 caller LLM이 timer polling, `ps`, state 파일 검사 또는 중복 실행을 하지 않는다. 3. **선택 Epic batch를 준비한다** - `WORKSPACE_READY` 뒤 스크립트가 선택 Epic을 문서 순서대로 하나씩 `prepare-epic-work-items`에 전달한다. @@ -111,6 +124,7 @@ Milestone workspace preparation - `[계획]`이 아니거나 잠긴 Milestone의 branch를 만들지 않는다. - consistency check의 `refresh-required`를 `ready`로 간주하지 않는다. - 사용자 소유 변경이 있는 develop checkout이나 기존 workspace를 덮어쓰지 않는다. +- 현재 workspace 실행 모드에서 target이 다른 branch/current를 자동 전환하지 않는다. - 선택 Epic 중 하나라도 attention/terminal failure 상태면 dispatcher를 시작하지 않는다. - 복수 Epic batch에서 개별 `EPIC_WORK_ITEMS_READY`만 보고 dispatcher를 먼저 시작하지 않는다. - `git push --force`, destructive rollback, branch/worktree 자동 삭제를 하지 않는다. diff --git a/agent-ops/skills/common/prepare-milestone-workspace/agents/openai.yaml b/agent-ops/skills/common/prepare-milestone-workspace/agents/openai.yaml index 86e9f1f2..bde6be49 100644 --- a/agent-ops/skills/common/prepare-milestone-workspace/agents/openai.yaml +++ b/agent-ops/skills/common/prepare-milestone-workspace/agents/openai.yaml @@ -1,4 +1,4 @@ interface: - display_name: "Prepare Milestone Work" - short_description: "Prepare selected Epics and start their dispatcher" - default_prompt: "Use $prepare-milestone-workspace to prepare the named Milestone in the given workspace through the selected Epic range and start its dispatcher." + display_name: "Start Milestone Epic Work" + short_description: "Prepare selected Milestone Epics and run dispatcher" + default_prompt: "Use $prepare-milestone-workspace to start the selected Epic range for the current Milestone workspace and run its dispatcher." diff --git a/agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_workspace.py b/agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_workspace.py index 23392a34..04db87e6 100755 --- a/agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_workspace.py +++ b/agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_workspace.py @@ -130,6 +130,28 @@ def milestone_contract(path: Path) -> dict[str, str]: } +def existing_milestone_contract(path: Path) -> dict[str, str]: + text = path.read_text(encoding="utf-8") + status_body = section(text, "상태") + status_match = re.search(r"^\[(.+?)\]\s*$", status_body, re.MULTILINE) + status = status_match.group(1).strip() if status_match else "" + lock = section(text, "구현 잠금") + lock_state = re.search(r"^- 상태:\s*(.+?)\s*$", lock, re.MULTILINE) + decision = re.search(r"^- 결정 필요:\s*(.+?)\s*$", lock, re.MULTILINE) + if status not in {"계획", "진행중"}: + raise PreparationError( + f"Milestone work requires [계획] or [진행중]: actual={status or 'missing'}" + ) + if not lock_state or lock_state.group(1).strip() != "해제": + raise PreparationError("milestone implementation lock is not 해제") + if not decision or decision.group(1).strip() != "없음": + raise PreparationError("milestone has unresolved 결정 필요") + return { + "title": first_heading(text, "# Milestone:"), + "status": status, + } + + def parse_epics(text: str) -> list[Epic]: lines = text.splitlines() starts: list[tuple[int, re.Match[str]]] = [] @@ -686,11 +708,23 @@ def coordinate_batch( } atomic_json(state_path, state) else: - for key, expected in identity.items(): - if state.get(key) != expected: - raise PreparationError( - f"Milestone preparation batch identity changed: field={key} state={state.get(key)} requested={expected}" - ) + mismatched = [key for key, expected in identity.items() if state.get(key) != expected] + if mismatched and state.get("status") == "completed": + state = { + **identity, + "status": "active", + "epic_events": {}, + "cross_epic_review_done": False, + "dispatcher_dry_run_done": False, + "dispatcher_live_started": False, + } + atomic_json(state_path, state) + elif mismatched: + key = mismatched[0] + raise PreparationError( + f"active Milestone preparation batch identity changed: field={key} " + f"state={state.get(key)} requested={identity[key]}" + ) if state.get("status") == "completed": emit( "MILESTONE_PREPARATION_COMPLETED", @@ -979,9 +1013,14 @@ def coordinate_batch( def parser() -> argparse.ArgumentParser: value = argparse.ArgumentParser(description=__doc__) - value.add_argument("--repo", required=True) + value.add_argument("--repo") value.add_argument("--milestone", required=True) - value.add_argument("--workspace", required=True) + value.add_argument("--workspace") + value.add_argument( + "--existing-workspace", + action="store_true", + help="start selected Epic work in the current prepared feature workspace", + ) value.add_argument( "--planner-agent", choices=sorted(VALID_AGENTS), @@ -1019,8 +1058,152 @@ def apply_defaults(args: argparse.Namespace) -> argparse.Namespace: return args +def prepare_existing(args: argparse.Namespace) -> int: + apply_defaults(args) + if not args.epics: + raise PreparationError("--epics is required with --existing-workspace") + if args.skip_agent_probe and not ( + args.dry_run or os.environ.get("AGENT_OPS_TESTING") == "1" + ): + raise PreparationError( + "--skip-agent-probe is test-only; use AGENT_OPS_TESTING=1" + ) + workspace = resolve_repo(args.workspace or ".") + if args.repo is not None and resolve_repo(args.repo) != workspace: + raise PreparationError("--repo must match --workspace in existing-workspace mode") + milestone_path, milestone_match = resolve_milestone(workspace, args.milestone) + milestone = existing_milestone_contract(milestone_path) + milestone_slug = milestone_match.group("slug") + phase_slug = milestone_match.group("phase") + selected = select_epics(parse_epics(milestone_path.read_text(encoding="utf-8")), args.epics) + batch_ids = [task_id for epic in selected for task_id in epic.task_ids] + identity = { + "milestone": str(milestone_path), + "workspace": str(workspace), + "selected_epics": [epic.epic_id for epic in selected], + "batch_task_ids": batch_ids, + } + develop = git(workspace, "config", "--get", "gitflow.branch.develop") + feature_prefix = git(workspace, "config", "--get", "gitflow.prefix.feature") + branch = git(workspace, "branch", "--show-current") + expected_branch = f"{feature_prefix}{milestone_slug}" if feature_prefix else "" + if not develop or not feature_prefix or branch != expected_branch: + raise PreparationError( + f"current workspace must use the target Milestone feature branch: " + f"expected={expected_branch or 'missing-gitflow-config'} actual={branch or 'detached'}" + ) + current_path = workspace / "agent-roadmap" / "current.md" + expected_current_target = f"phase/{phase_slug}/milestones/{milestone_slug}.md" + if ( + not current_path.is_file() + or expected_current_target not in current_path.read_text(encoding="utf-8") + ): + raise PreparationError( + f"workspace-local current does not select target Milestone: {current_path}" + ) + reviewer_agent = args.review_agent or args.planner_agent + reviewer_model = args.review_model or ( + args.planner_model if reviewer_agent == args.planner_agent else None + ) + for agent in {args.planner_agent, reviewer_agent}: + command = AGENT_COMMAND[agent] + if shutil.which(command) is None and not args.skip_agent_probe: + raise PreparationError(f"agent command not found: agent={agent} command={command}") + + common = git_common_dir(workspace) + state_root = common / "milestone-work-preparation" / milestone_slug + state_root.mkdir(parents=True, exist_ok=True) + state_path = state_root / "workspace-state.json" + + with (state_root / "workspace.lock").open("a+", encoding="utf-8") as lock: + try: + fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as exc: + raise PreparationError( + f"workspace preparation already running: {state_root / 'workspace.lock'}" + ) from exc + batch_state = read_json(state_root / "batch-state.json") + batch_matches = bool( + batch_state + and all(batch_state.get(key) == expected for key, expected in identity.items()) + ) + resuming_batch = bool( + batch_matches and batch_state and batch_state.get("status") != "completed" + ) + if batch_state and batch_state.get("status") != "completed" and not batch_matches: + raise PreparationError("another active Epic batch owns the current Milestone workspace") + if not resuming_batch: + ensure_clean(workspace, "feature workspace") + upstream = git( + workspace, + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{u}", + ) + if not upstream.endswith(f"/{branch}"): + raise PreparationError( + f"feature branch upstream mismatch: branch={branch} upstream={upstream}" + ) + if not resuming_batch and not args.dry_run: + remote_name = upstream.split("/", 1)[0] + git(workspace, "fetch", remote_name, branch) + head = git(workspace, "rev-parse", "HEAD") + upstream_head = git(workspace, "rev-parse", "@{u}") + if head != upstream_head: + if not resuming_batch or run( + ["git", "merge-base", "--is-ancestor", "@{u}", "HEAD"], + cwd=workspace, + check=False, + ).returncode != 0: + raise PreparationError("current feature branch is not synchronized with its upstream") + emit( + "PREFLIGHT_READY", + branch=branch, + milestone=str(milestone_path), + workspace=str(workspace), + existing_workspace=True, + ) + if args.dry_run: + return 0 + if not args.skip_agent_probe and not resuming_batch: + probe_agents( + workspace, + args.planner_agent, + reviewer_agent, + args.planner_model, + reviewer_model, + args.reasoning_effort, + args.pi_provider, + ) + ensure_clean(workspace, "feature workspace after agent probe") + state = { + "status": "workspace-ready", + "milestone": str(milestone_path), + "milestone_slug": milestone_slug, + "branch": branch, + "workspace": str(workspace), + "planner_agent": args.planner_agent, + "review_agent": reviewer_agent, + "reasoning_effort": args.reasoning_effort, + "existing_workspace": True, + } + atomic_json(state_path, state) + emit("WORKSPACE_READY", **state) + return coordinate_batch( + args=args, + workspace=workspace, + milestone=milestone_path, + milestone_slug=milestone_slug, + phase_slug=phase_slug, + common=common, + ) + + def prepare(args: argparse.Namespace) -> int: apply_defaults(args) + if args.repo is None or args.workspace is None: + raise PreparationError("--repo and --workspace are required unless --existing-workspace is used") if args.skip_agent_probe and not ( args.dry_run or os.environ.get("AGENT_OPS_TESTING") == "1" ): @@ -1205,7 +1388,7 @@ def prepare(args: argparse.Namespace) -> int: def main(argv: Iterable[str] | None = None) -> int: args = apply_defaults(parser().parse_args(argv)) try: - return prepare(args) + return prepare_existing(args) if args.existing_workspace else prepare(args) except (OSError, PreparationError) as exc: emit("FAILED", reason=str(exc)) return 2 diff --git a/agent-ops/skills/common/prepare-milestone-workspace/tests/test_prepare_workspace.py b/agent-ops/skills/common/prepare-milestone-workspace/tests/test_prepare_workspace.py index 5c9cb088..bb69d0fe 100644 --- a/agent-ops/skills/common/prepare-milestone-workspace/tests/test_prepare_workspace.py +++ b/agent-ops/skills/common/prepare-milestone-workspace/tests/test_prepare_workspace.py @@ -169,6 +169,117 @@ class PrepareWorkspaceTest(unittest.TestCase): self.assertIn("sample-milestone.md", current) self.assertFalse(command(worktree, "git", "status", "--porcelain=v1")) + def test_existing_feature_workspace_starts_selected_epic_batch(self) -> None: + with tempfile.TemporaryDirectory() as raw: + root = Path(raw) + remote = root / "remote.git" + workspace = root / "workspace" + command(root, "git", "init", "--bare", str(remote)) + command(root, "git", "init", "-b", "dev", str(workspace)) + command(workspace, "git", "config", "user.name", "Test Agent") + command(workspace, "git", "config", "user.email", "agent@example.test") + command(workspace, "git", "config", "gitflow.branch.develop", "dev") + command(workspace, "git", "config", "gitflow.prefix.feature", "feature/") + command(workspace, "git", "remote", "add", "origin", str(remote)) + milestone = ( + workspace + / "agent-roadmap" + / "phase" + / "phase-one" + / "milestones" + / "sample-milestone.md" + ) + milestone.parent.mkdir(parents=True) + milestone.write_text( + "# Milestone: Sample\n\n" + "## 상태\n\n[계획]\n\n" + "## 구현 잠금\n\n- 상태: 해제\n- 결정 필요: 없음\n\n" + "## 기능\n\n" + "### Epic: [first] First\n\n- [ ] [first-task] first\n\n" + "### Epic: [second] Second\n\n- [ ] [second-task] second\n", + encoding="utf-8", + ) + (workspace / ".gitignore").write_text( + "agent-roadmap/current.md\n", encoding="utf-8" + ) + command(workspace, "git", "add", ".gitignore", "agent-roadmap") + command(workspace, "git", "commit", "-m", "init") + command(workspace, "git", "push", "-u", "origin", "dev") + command(workspace, "git", "switch", "-c", "feature/sample-milestone") + command(workspace, "git", "push", "-u", "origin", "feature/sample-milestone") + (workspace / "agent-roadmap" / "current.md").write_text( + "# Current\n\n" + "- [Sample](phase/phase-one/milestones/sample-milestone.md)\n", + encoding="utf-8", + ) + output = io.StringIO() + with ( + contextlib.redirect_stdout(output), + mock.patch.dict(os.environ, {"AGENT_OPS_TESTING": "1"}), + mock.patch.object(MODULE, "coordinate_batch", return_value=0) as coordinate, + ): + result = MODULE.main( + [ + "--existing-workspace", + "--workspace", + str(workspace), + "--milestone", + str(milestone.relative_to(workspace)), + "--epics", + "2..2", + "--skip-agent-probe", + ] + ) + self.assertEqual(result, 0, output.getvalue()) + self.assertIn('"existing_workspace": true', output.getvalue()) + self.assertEqual(coordinate.call_args.kwargs["workspace"], workspace) + self.assertEqual(coordinate.call_args.kwargs["milestone"], milestone) + self.assertEqual(coordinate.call_args.kwargs["args"].epics, "2..2") + self.assertFalse(command(workspace, "git", "status", "--porcelain=v1")) + + def test_existing_workspace_mode_refuses_non_target_branch(self) -> None: + with tempfile.TemporaryDirectory() as raw: + workspace = Path(raw) / "workspace" + command(workspace.parent, "git", "init", "-b", "dev", str(workspace)) + command(workspace, "git", "config", "gitflow.branch.develop", "dev") + command(workspace, "git", "config", "gitflow.prefix.feature", "feature/") + milestone = ( + workspace + / "agent-roadmap" + / "phase" + / "phase-one" + / "milestones" + / "sample.md" + ) + milestone.parent.mkdir(parents=True) + milestone.write_text( + "# Milestone: Sample\n\n" + "## 상태\n\n[계획]\n\n" + "## 구현 잠금\n\n- 상태: 해제\n- 결정 필요: 없음\n\n" + "## 기능\n\n### Epic: [first] First\n\n- [ ] [first-task] first\n", + encoding="utf-8", + ) + (workspace / "agent-roadmap" / "current.md").write_text( + "phase/phase-one/milestones/sample.md\n", encoding="utf-8" + ) + output = io.StringIO() + with contextlib.redirect_stdout(output): + result = MODULE.main( + [ + "--existing-workspace", + "--workspace", + str(workspace), + "--milestone", + str(milestone.relative_to(workspace)), + "--epics", + "1..1", + "--dry-run", + "--skip-agent-probe", + ] + ) + self.assertEqual(result, 2) + self.assertIn("current workspace must use", output.getvalue()) + def test_two_epic_batch_opens_dispatcher_barrier_once_after_both(self) -> None: with tempfile.TemporaryDirectory() as raw: root = Path(raw) @@ -409,6 +520,77 @@ class PrepareWorkspaceTest(unittest.TestCase): self.assertEqual(result, 3) popen.assert_not_called() + def test_completed_batch_can_start_a_different_epic_selection(self) -> None: + with tempfile.TemporaryDirectory() as raw: + root = Path(raw) + workspace = root / "workspace" + common = root / "git-common" + milestone = workspace / "agent-roadmap/phase/phase-one/milestones/sample.md" + milestone.parent.mkdir(parents=True) + milestone.write_text( + "# Milestone: Sample\n\n## 기능\n\n" + "### Epic: [first] First\n\n- [x] [first-task] first\n\n" + "### Epic: [second] Second\n\n- [x] [second-task] second\n", + encoding="utf-8", + ) + args = MODULE.apply_defaults( + MODULE.parser().parse_args( + [ + "--milestone", + str(milestone), + "--workspace", + str(workspace), + "--epics", + "2..2", + ] + ) + ) + batch_state = common / "milestone-work-preparation" / "sample" / "batch-state.json" + MODULE.atomic_json( + batch_state, + { + "milestone": str(milestone), + "workspace": str(workspace), + "selected_epics": ["first"], + "batch_task_ids": ["first-task"], + "status": "completed", + "epic_events": {"first": "EPIC_COMPLETED"}, + }, + ) + + def fake_run(command: list[str], **_: object) -> subprocess.CompletedProcess[str]: + if "--validate-only" not in command: + epic_state = ( + common + / "epic-work-preparation" + / "sample" + / "second" + / "state.json" + ) + MODULE.atomic_json( + epic_state, + {"status": "completed", "event": "EPIC_COMPLETED"}, + ) + return subprocess.CompletedProcess(command, 0) + + with ( + mock.patch.object(MODULE, "epic_cycle_script", return_value=Path("/cycle.py")), + mock.patch.object(MODULE, "run", side_effect=fake_run), + mock.patch.object(MODULE, "git", return_value="head"), + mock.patch.object(MODULE.subprocess, "Popen") as popen, + ): + result = MODULE.coordinate_batch( + args=args, + workspace=workspace, + milestone=milestone, + milestone_slug="sample", + phase_slug="phase-one", + common=common, + ) + self.assertEqual(result, 0) + self.assertEqual(MODULE.read_json(batch_state)["selected_epics"], ["second"]) + popen.assert_not_called() + def test_recovered_cross_epic_review_still_validates_and_publishes(self) -> None: with tempfile.TemporaryDirectory() as raw: root = Path(raw) diff --git a/agent-ops/skills/common/router.md b/agent-ops/skills/common/router.md index 12bb36ce..c26130e0 100644 --- a/agent-ops/skills/common/router.md +++ b/agent-ops/skills/common/router.md @@ -47,7 +47,7 @@ | 이 마일스톤은 X가 끝나야 가능해, A 전까지 B 잠가둬, 잠금 해제 조건은 X야, X 프로젝트 작업 뒤에 현재 마일스톤 진행, 의존성 설정해, 외부 의존 잠금 | `agent-ops/skills/common/update-roadmap/SKILL.md` | | roadmap dependency 확인, locks.yaml 판별, 외부 의존 잠금 확인, unlock-ready 판별, 잠금 해제 조건 충족 여부 확인, roadmap-dependency-checker.sh | `agent-ops/skills/common/check-roadmap-dependency/SKILL.md` | | 지금 작업이 뭐지?, 현재 작업 분석, 어디까지 했지?, 로드맵상 현 위치, 현재 마일스톤 위치, current 기준 breadcrumb | `agent-ops/skills/common/analyze-roadmap-position/SKILL.md` | -| X에 Y 작업 준비해, X 위치에 Y 작업 준비해, X에 Y 작업준비해, X에 Y 마일스톤 작업 준비해, X에 Y 작업 준비하고 Epic은 N번째까지 진행해 | `agent-ops/skills/common/prepare-milestone-workspace/SKILL.md` | +| X에 Y 작업 준비해, X 위치에 Y 작업 준비해, 현 마일스톤에 N번째 에픽 작업 시작해, 현 마일스톤에 N번째 에픽까지 작업 시작해, Y 마일스톤에 1,2번째 에픽까지 작업 시작해 | `agent-ops/skills/common/prepare-milestone-workspace/SKILL.md` | | 현 마일스톤 Epic 작업 준비해, 마일스톤 Epic 작업 준비해, 이 Epic의 작은 작업은 바로 처리하고 큰 작업은 plan으로 작성해, Epic 작업을 작은 작업과 plan으로 나눠 | `agent-ops/skills/common/prepare-epic-work-items/SKILL.md` | | 계획 세워줘, 계획 작성해, 계획 만들어줘, 구현 계획, PLAN.md, plan, plan 작성해, plan 만들어줘 | `agent-ops/skills/common/plan/SKILL.md` | | 현재 plan들 세분화해, 현재 plan 세분화, 기존 plan 더 나눠, task 세분화해, plan 분리해 | `agent-ops/skills/common/refine-plans/SKILL.md` | @@ -61,7 +61,9 @@ 라우팅 우선순위: -- `X에 Y 작업 준비해`처럼 workspace 위치와 대상 Milestone이 함께 명시되면 `prepare-milestone-workspace`를 선택한다. 상대 workspace는 develop repository root 기준으로 해석한다. Epic 범위가 없으면 첫 미완료 Epic 하나, `N번째 Epic까지`이면 문서 순서 `1..N`을 선택한다. 선택 Epic을 각각 `prepare-epic-work-items`로 준비하되 전체 `MILESTONE_WORK_ITEMS_READY` 전에는 dispatcher를 시작하지 않는다. workspace 위치가 없으면 이 흐름으로 라우팅하지 않고 확인을 요청한다. +- `X에 Y 작업 준비해`처럼 workspace 위치와 대상 Milestone이 함께 명시되면 `prepare-milestone-workspace` 생성 모드를 선택한다. 상대 workspace는 develop repository root 기준으로 해석한다. 이 형식에서 workspace 위치가 없으면 확인을 요청한다. +- `현 마일스톤에 N번째 에픽 작업 시작해` 또는 `Y 마일스톤에 1,2번째 에픽까지 작업 시작해`는 같은 스킬의 현재 workspace 실행 모드를 선택한다. `현 마일스톤`은 current/feature branch의 단일 일치 target, 이름 있는 Milestone은 현재 workspace branch/current와 정확히 일치하는 target만 허용한다. `N번째`는 `N..N`, `N번째까지`는 `1..N`, `1,2번째까지`는 `1..2`로 해석한다. 현재 workspace가 준비되지 않았거나 target과 다르면 workspace를 추정·전환하지 않고 거부한다. +- 두 모드 모두 선택 Epic을 각각 `prepare-epic-work-items`로 준비하되 전체 `MILESTONE_WORK_ITEMS_READY` 전에는 dispatcher를 시작하지 않는다. - 한 Epic 안에서 작은 작업 직접 처리와 큰 작업 plan 작성을 함께 요청하면 `prepare-epic-work-items`를 선택한다. 이미 존재하는 plan만 세분화하는 요청과 새로운 plan만 작성하는 요청에는 이 스킬을 선택하지 않는다. - 이미 생성된 미착수 pair의 분할만 요청하면 lane과 관계없이 `refine-plans`를 선택한다. 새 plan 작성이나 구현 범위 재분석이 포함되면 `plan`을 선택한다. - `refine-plans` 대상이 아닌 PLAN/CODE_REVIEW 작성 또는 재작성이 요청 범위에 포함되면 `plan`을 선택한다. `plan`이 최종 단계에서 `finalize-task-routing`을 필수 호출한다.