sync: to agentic-framework v1.1.185

This commit is contained in:
toki 2026-08-03 19:02:37 +09:00
parent 1616b03a87
commit 3d5480264e
9 changed files with 1330 additions and 66 deletions

View file

@ -1 +1 @@
1.1.184
1.1.185

View file

@ -20,13 +20,16 @@ description: 현재 또는 지정 Milestone의 정확히 한 Epic을 작은 직
- `reasoning-effort`: 지원하는 provider의 reasoning/thinking override. 생략 시 `xhigh` (선택)
- `pi-provider`: Pi provider override (선택)
- `retry`: terminal failure의 원인을 사용자가 해소한 뒤 같은 Epic 상태를 재개할 때만 사용한다. (선택)
- `batch-task-ids`: 상위 `prepare-milestone-workspace`가 고정한 선택 Epic Task id 합집합. 직접 호출에서는 사용하지 않는다. (내부 선택)
## 범위 계약
- 한 실행은 Epic 하나만 다룬다. Epic 범위 요청은 caller가 문서 순서대로 하나씩 실행한다.
- 한 실행은 Epic 하나만 다룬다. Epic 범위 요청은 `prepare-milestone-workspace` coordinator가 문서 순서대로 하나씩 실행한다.
- 실행 identity는 `<milestone-slug>:<epic-id>`다.
- 다음 Epic은 현재 Epic의 모든 Task가 workstate sync에서 완료된 `EPIC_COMPLETED` event 뒤에만 시작한다.
- 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_WORK_ITEMS_READY`는 큰 작업 plan이 준비됐다는 뜻이며 구현 완료가 아니다.
- 개별 `EPIC_WORK_ITEMS_READY`는 dispatcher 시작 신호가 아니다. 복수 선택의 dispatcher gate는 상위 coordinator의 `MILESTONE_WORK_ITEMS_READY` 하나다.
- 같은 identity를 다시 실행하면 active pair, USER_REVIEW, runtime state를 먼저 대조하고 중복 plan을 만들지 않는다.
작은 작업은 아래를 모두 만족해야 한다.
@ -42,7 +45,7 @@ description: 현재 또는 지정 Milestone의 정확히 한 Epic을 작은 직
1. **Epic을 고정한다**
- target Milestone이 `[계획]` 또는 `[진행중]`, 구현 잠금 해제인지 확인한다.
- `### Epic: [<epic-id>] <title>`을 정확히 하나 찾고 그 아래 Task id를 고정한다.
- 다른 Epic의 active pair가 있거나 target이 모호하면 `FAILED`로 멈춘다.
- standalone에서 다른 Epic의 active pair가 있거나 target이 모호하면 `FAILED`로 멈춘다. 상위 batch에서는 선택 Task id 합집합 밖 pair 또는 Epic 경계를 가로지르는 pair만 거부한다.
2. **foreground 사이클을 실행한다**
- 아래 스크립트를 한 번 실행하고 execution-layer event wait를 유지한다.
@ -56,6 +59,7 @@ python3 agent-ops/skills/common/prepare-epic-work-items/scripts/run_epic_cycle.p
- 기본값은 `codex / gpt-5.6-sol / xhigh`다. 다른 provider를 지정하면 모델을 별도로 주지 않는 한 해당 provider의 CLI 기본 모델을 사용한다.
- 다른 agent, model, reasoning, Pi provider override와 `--retry`는 해당 입력이 있을 때만 전달한다.
- 상위 batch에서 호출할 때만 고정된 Task id 합집합을 `--batch-task-ids`로 전달한다.
- 스크립트는 각 agent를 새 one-shot session으로 실행한다. Codex, Claude, Gemini(`agy` adapter), Pi를 같은 normalized runner 계약으로 지원한다.
- model stdout/stderr는 git common dir의 locator log에만 저장한다. caller stdout에는 lifecycle/attention event만 출력한다.
@ -85,6 +89,7 @@ python3 agent-ops/skills/common/prepare-epic-work-items/scripts/run_epic_cycle.p
- `FINAL_REVIEW_STARTED`, `FINAL_REVIEW_FINISHED`
- `FINAL_ARTIFACTS_PUSHED`
- `AGENT_TRACKING`, `AGENT_RECOVERY_REQUIRED`, `AGENT_RESULT_RECOVERED`
- `EPIC_BATCH_VALIDATED` (상위 batch의 deterministic barrier 검증)
- `EPIC_WORK_ITEMS_READY`, `EPIC_COMPLETED`, `USER_REVIEW`, `FAILED`
routine event는 caller 판단을 요구하지 않는다. caller는 `USER_REVIEW`, `AGENT_RECOVERY_REQUIRED`, 복구 불가능한 `FAILED`, terminal completion에서만 깨어난다.
@ -93,6 +98,7 @@ routine event는 caller 판단을 요구하지 않는다. caller는 `USER_REVIEW
- [ ] active PLAN/CODE_REVIEW가 항상 pair이고 첫 줄 metadata가 일치하는가
- [ ] 모든 `milestone-task`가 target Epic Task id의 비어 있지 않은 부분집합인가
- [ ] 상위 batch 호출이면 다른 pair도 선택 batch 합집합 안에 있고 target Epic 경계를 가로지르지 않는가
- [ ] 모든 PLAN이 dispatcher `--validate-plan`을 통과하는가
- [ ] refine 전후 Task id 합집합과 scope가 보존됐는가
- [ ] repository에 unresolved template token이나 preparation runtime state가 추적되지 않는가

View file

@ -317,26 +317,45 @@ def validate_pairs(
workspace: Path,
task_group: str,
epic_task_ids: set[str],
allowed_task_ids: set[str] | None = None,
) -> tuple[list[tuple[Path, Path, dict[str, str]]], set[str]]:
pairs = active_pairs(workspace, task_group)
all_pairs = active_pairs(workspace, task_group)
allowed = set(epic_task_ids) if allowed_task_ids is None else set(allowed_task_ids)
if not epic_task_ids <= allowed:
raise CycleError("target Epic Task ids must be inside the selected batch")
pairs: list[tuple[Path, Path, dict[str, str]]] = []
union: set[str] = set()
dispatcher = (
workspace
/ "agent-ops"
/ "skills"
/ "common"
/ "orchestrate-agent-task-loop"
/ "scripts"
/ "dispatch.py"
project_dispatcher = (
workspace / "agent-ops" / "skills" / "project" / "orchestrate-agent-task-loop"
)
for plan, review, header in pairs:
private_dispatcher = (
workspace / "agent-ops" / "skills" / "private" / "orchestrate-agent-task-loop"
)
if project_dispatcher.is_dir() and private_dispatcher.is_dir():
dispatcher_root = private_dispatcher
elif project_dispatcher.is_dir():
dispatcher_root = project_dispatcher
else:
dispatcher_root = (
workspace / "agent-ops" / "skills" / "common" / "orchestrate-agent-task-loop"
)
dispatcher = dispatcher_root / "scripts" / "dispatch.py"
for plan, review, header in all_pairs:
ids = header["milestone-task"].split(",")
if not ids or any(not value for value in ids) or len(ids) != len(set(ids)):
raise CycleError(f"invalid milestone-task list: {plan}")
outside = sorted(set(ids) - epic_task_ids)
pair_ids = set(ids)
outside = sorted(pair_ids - allowed)
if outside:
raise CycleError(f"plan includes Task ids outside target Epic: {plan}: {','.join(outside)}")
union.update(ids)
raise CycleError(
f"plan includes Task ids outside selected Epic batch: {plan}: {','.join(outside)}"
)
target_ids = pair_ids & epic_task_ids
if target_ids and target_ids != pair_ids:
raise CycleError(f"plan crosses target Epic boundary: {plan}")
if target_ids:
pairs.append((plan, review, header))
union.update(target_ids)
contents = plan.read_text(encoding="utf-8") + "\n" + review.read_text(encoding="utf-8")
if "[TODO" in contents or "<task_group>" in contents or "<milestone-slug>" in contents:
raise CycleError(f"unresolved template token: {plan.parent}")
@ -349,6 +368,19 @@ def validate_pairs(
return pairs, union
def batch_task_ids(raw: str | None, epic_task_ids: tuple[str, ...]) -> set[str]:
target = set(epic_task_ids)
if raw is None:
return target
values = raw.split(",")
if not values or any(not value for value in values) or len(values) != len(set(values)):
raise CycleError("--batch-task-ids must be a unique comma-separated Task id list")
allowed = set(values)
if not target <= allowed:
raise CycleError("--batch-task-ids does not include every target Epic Task id")
return allowed
def active_task_user_reviews(workspace: Path, task_group: str) -> list[Path]:
root = workspace / "agent-task" / task_group
if not root.exists():
@ -538,6 +570,15 @@ def parser() -> argparse.ArgumentParser:
value.add_argument("--review-model")
value.add_argument("--reasoning-effort", default=DEFAULT_REASONING_EFFORT)
value.add_argument("--pi-provider")
value.add_argument(
"--batch-task-ids",
help="internal selected-Epic Task id union; permits earlier Epic pairs in the same batch",
)
value.add_argument(
"--validate-only",
action="store_true",
help="validate the selected Epic against the current batch without running an agent",
)
value.add_argument("--retry", action="store_true")
return value
@ -563,6 +604,7 @@ def cycle(args: argparse.Namespace) -> int:
identity = f"{milestone_slug}:{epic.epic_id}"
if not epic.task_ids:
raise CycleError(f"target Epic has no Task ids: {epic.epic_id}")
allowed_task_ids = batch_task_ids(args.batch_task_ids, epic.task_ids)
feature_prefix = git(workspace, "config", "--get", "gitflow.prefix.feature")
branch = git(workspace, "branch", "--show-current")
expected_branch = f"{feature_prefix}{milestone_slug}"
@ -572,6 +614,31 @@ def cycle(args: argparse.Namespace) -> int:
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 CycleError(f"workspace-local current does not select target Milestone: {current_path}")
if args.validate_only:
pairs, task_union = validate_pairs(
workspace,
task_group,
set(epic.task_ids),
allowed_task_ids,
)
completed_with_plan = sorted(task_union - set(epic.incomplete_ids))
if completed_with_plan:
raise CycleError(
"completed Task ids still have active plans: " + ",".join(completed_with_plan)
)
remaining_without_plan = sorted(set(epic.incomplete_ids) - task_union)
if remaining_without_plan:
raise CycleError(
"incomplete Epic Task ids have neither completion sync nor active plans: "
+ ",".join(remaining_without_plan)
)
emit(
"EPIC_BATCH_VALIDATED",
identity=identity,
event="EPIC_COMPLETED" if not epic.incomplete_ids else "EPIC_WORK_ITEMS_READY",
plans=len(pairs),
)
return 0
state_root = git_common_dir(workspace) / "epic-work-preparation" / milestone_slug / epic.epic_id
state_path = state_root / "state.json"
state_root.mkdir(parents=True, exist_ok=True)
@ -591,13 +658,24 @@ def cycle(args: argparse.Namespace) -> int:
and state.get("task_ids") != list(epic.task_ids)
):
raise CycleError("target Epic Task ids changed after cycle scope was fixed")
if (
state
and state.get("batch_task_ids") is not None
and state.get("batch_task_ids") != sorted(allowed_task_ids)
):
raise CycleError("selected Epic batch Task ids changed after cycle scope was fixed")
if state and state.get("status") == "completed":
if changed_paths(workspace):
raise CycleError("completed cycle requires a clean feature workspace")
refreshed = select_epic(
parse_epics(milestone_path.read_text(encoding="utf-8")), epic.epic_id
)
pairs, task_union = validate_pairs(workspace, task_group, set(refreshed.task_ids))
pairs, task_union = validate_pairs(
workspace,
task_group,
set(refreshed.task_ids),
allowed_task_ids,
)
completed_with_plan = sorted(task_union - set(refreshed.incomplete_ids))
if completed_with_plan:
raise CycleError(
@ -630,7 +708,12 @@ def cycle(args: argparse.Namespace) -> int:
if active_task_user_reviews(workspace, task_group):
raise CycleError("preparation cannot resume from agent-task USER_REVIEW")
if not epic.incomplete_ids:
existing, _ = validate_pairs(workspace, task_group, set(epic.task_ids))
existing, _ = validate_pairs(
workspace,
task_group,
set(epic.task_ids),
allowed_task_ids,
)
if existing:
raise CycleError(
"completed Epic still has active PLAN/CODE_REVIEW pairs; reconcile them before completion"
@ -638,6 +721,7 @@ def cycle(args: argparse.Namespace) -> int:
state = {
"identity": identity,
"task_ids": list(epic.task_ids),
"batch_task_ids": sorted(allowed_task_ids),
"status": "completed",
"event": "EPIC_COMPLETED",
"head": current_head,
@ -645,12 +729,18 @@ def cycle(args: argparse.Namespace) -> int:
atomic_json(state_path, state)
emit("EPIC_COMPLETED", identity=identity)
return 0
existing, _ = validate_pairs(workspace, task_group, set(epic.task_ids))
existing, _ = validate_pairs(
workspace,
task_group,
set(epic.task_ids),
allowed_task_ids,
)
if existing:
raise CycleError("active pair already exists before new Epic cycle; select recovery explicitly")
state = {
"identity": identity,
"task_ids": list(epic.task_ids),
"batch_task_ids": sorted(allowed_task_ids),
"status": "active",
"next_stage": STAGES[0],
"base_head": current_head,
@ -744,7 +834,12 @@ def cycle(args: argparse.Namespace) -> int:
)
if refreshed_scope.task_ids != epic.task_ids:
raise CycleError(f"target Epic Task ids changed unexpectedly: stage={stage}")
pairs, task_union = validate_pairs(workspace, task_group, set(epic.task_ids))
pairs, task_union = validate_pairs(
workspace,
task_group,
set(epic.task_ids),
allowed_task_ids,
)
if active_task_user_reviews(workspace, task_group):
raise CycleError("preparation agent created forbidden agent-task USER_REVIEW")
if review_path.exists():
@ -818,26 +913,27 @@ def main(argv: Iterable[str] | None = None) -> int:
return cycle(args)
except (CycleError, OSError, ValueError) as exc:
try:
workspace = Path(args.workspace).expanduser().resolve()
milestone = Path(args.milestone)
if not milestone.is_absolute():
milestone = (workspace / milestone).resolve()
match = MILESTONE_PATTERN.fullmatch(milestone.relative_to(workspace).as_posix())
if match:
selected = select_epic(
parse_epics(milestone.read_text(encoding="utf-8")), args.epic
)
identity = f"{match.group('slug')}:{selected.epic_id}"
state_path = (
git_common_dir(workspace)
/ "epic-work-preparation"
/ match.group("slug")
/ selected.epic_id
/ "state.json"
)
prior = read_state(state_path) or {"identity": identity}
prior.update(status="failed", event="FAILED", reason=str(exc))
atomic_json(state_path, prior)
if not args.validate_only:
workspace = Path(args.workspace).expanduser().resolve()
milestone = Path(args.milestone)
if not milestone.is_absolute():
milestone = (workspace / milestone).resolve()
match = MILESTONE_PATTERN.fullmatch(milestone.relative_to(workspace).as_posix())
if match:
selected = select_epic(
parse_epics(milestone.read_text(encoding="utf-8")), args.epic
)
identity = f"{match.group('slug')}:{selected.epic_id}"
state_path = (
git_common_dir(workspace)
/ "epic-work-preparation"
/ match.group("slug")
/ selected.epic_id
/ "state.json"
)
prior = read_state(state_path) or {"identity": identity}
prior.update(status="failed", event="FAILED", reason=str(exc))
atomic_json(state_path, prior)
except Exception:
pass
emit("FAILED", identity=identity, reason=str(exc), state=str(state_path) if state_path else None)

View file

@ -161,6 +161,54 @@ class EpicCycleContractTest(unittest.TestCase):
with self.assertRaises(MODULE.CycleError):
MODULE.validate_pairs(workspace, "m-sample", {"inside"})
def test_validate_pair_allows_earlier_epic_pair_in_selected_batch(self) -> None:
with tempfile.TemporaryDirectory() as raw:
workspace = Path(raw)
command(workspace, "git", "init")
root = workspace / "agent-task" / "m-sample"
first = root / "01_first"
second = root / "02_second"
first.mkdir(parents=True)
second.mkdir(parents=True)
for task, task_id in ((first, "first-task"), (second, "second-task")):
header = (
f"<!-- task=m-sample/{task.name} plan=0 tag=TEST "
f"milestone-task={task_id} -->\n"
)
(task / "PLAN-local-G01.md").write_text(header + "# Plan\n", encoding="utf-8")
(task / "CODE_REVIEW-local-G01.md").write_text(
header + "# Review\n", encoding="utf-8"
)
pairs, union = MODULE.validate_pairs(
workspace,
"m-sample",
{"second-task"},
{"first-task", "second-task"},
)
self.assertEqual([pair[0].parent.name for pair in pairs], ["02_second"])
self.assertEqual(union, {"second-task"})
def test_validate_pair_rejects_foreign_pair_outside_selected_batch(self) -> None:
with tempfile.TemporaryDirectory() as raw:
workspace = Path(raw)
task = workspace / "agent-task" / "m-sample" / "03_foreign"
task.mkdir(parents=True)
header = (
"<!-- task=m-sample/03_foreign plan=0 tag=TEST "
"milestone-task=foreign-task -->\n"
)
(task / "PLAN-local-G01.md").write_text(header + "# Plan\n", encoding="utf-8")
(task / "CODE_REVIEW-local-G01.md").write_text(
header + "# Review\n", encoding="utf-8"
)
with self.assertRaisesRegex(MODULE.CycleError, "outside selected Epic batch"):
MODULE.validate_pairs(
workspace,
"m-sample",
{"second-task"},
{"first-task", "second-task"},
)
def test_full_cycle_with_fresh_fake_codex_passes_and_pushes(self) -> None:
with tempfile.TemporaryDirectory() as raw:
root = Path(raw)

View file

@ -1,28 +1,29 @@
---
name: prepare-milestone-workspace
description: 명시된 workspace에 계획 상태의 Milestone을 현재 작업현황과 동기화한 뒤 Git Flow feature branch를 Milestone slug로 생성·push하고 worktree로 준비해 첫 Epic 작업 사이클로 넘길 때 사용한다. "workspace에 milestone 작업 준비해" 요청에서 사용한다.
description: 명시된 절대 또는 repository-root-relative workspace에 계획 상태의 Milestone을 동기화하고 Git Flow feature branch/worktree를 준비한 뒤, 선택한 한 개 또는 여러 Epic을 검토된 작업으로 변환하고 전체 준비 배리어 뒤 dispatcher를 시작할 때 사용한다. "../iop-s1 위치에 X 작업 준비해", "X에 Y 작업준비해. Epic은 두 번째까지 진행해" 요청에서 사용한다.
---
# Prepare Milestone Workspace
`<workspace>에 <milestone> 작업 준비해` 요청은 workspace만 추정하거나 만드는 요청이 아니라, 지정된 workspace에 대상 Milestone의 정합성 확인부터 첫 Epic 준비 사이클 연결까지 수행하는 전체 흐름으로 해석한다.
`<workspace>에 <milestone> 작업 준비해` 요청은 workspace만 만드는 요청이 아니다. 대상 Milestone 정합성 확인부터 선택 Epic 준비와 dispatcher 전환까지 수행하는 전체 흐름으로 해석한다.
## 목적
계획 가능한 Milestone 하나를 검증된 `feature/<milestone-slug>` workspace로 전환한다. 의미 정합성은 기존 roadmap 스킬이, branch/worktree/current와 lifecycle event는 번들 스크립트가 소유한다.
계획 가능한 Milestone 하나를 검증된 `feature/<milestone-slug>` workspace로 전환하고, 선택 Epic 전체가 준비된 뒤에만 구현 dispatcher를 시작한다. 의미 정합성은 roadmap 스킬이, branch/worktree/current와 batch lifecycle은 번들 스크립트가 소유한다.
## 입력
- `target-milestone`: 활성 Milestone 이름, id, slug 또는 경로 (필수)
- `workspace`: feature worktree로 사용할 절대 경로 (필수)
- `workspace`: feature worktree로 사용할 절대 경로 또는 develop 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 기본 모델을 사용한다. (선택)
- `reasoning-effort`: 지원하는 provider의 reasoning/thinking override. 생략 시 `xhigh` (선택)
- `pi-provider`: Pi provider override (선택)
- `target-epic`: 준비 직후 실행할 정확한 Epic id 또는 이름. 생략하면 첫 미완료 Epic을 선택한다. (선택)
- `target-epics`: `first-incomplete`, 정확한 Epic id/title의 comma list, 또는 문서 순서의 1-based inclusive range `N..M`. 생략하면 `first-incomplete`를 사용한다. (선택)
- `retry`: 기록된 attention/recovery 조건을 사용자가 해소한 뒤 batch를 재개할 때만 사용한다. (선택)
트리거의 첫 번째 위치 표현(`<workspace>`)은 workspace 절대 경로로, 두 번째 표현(`<milestone>`)은 대상 Milestone으로 각각 확정한다. workspace가 생략되거나 둘 이상의 경로로 해석되면 임의로 `current`나 기본 경로를 사용하지 않고 사용자에게 확인한다.
트리거의 첫 번째 위치 표현(`<workspace>`)은 workspace로, 두 번째 표현(`<milestone>`)은 대상 Milestone으로 각각 확정한다. 상대 workspace는 develop repository root 기준으로 해석한다. `두 번째 Epic까지``1..2`, `세 번째부터 네 번째 Epic까지``3..4`로 변환한다. workspace가 생략되거나 둘 이상의 경로로 해석되면 임의로 `current`나 기본 경로를 사용하지 않고 사용자에게 확인한다.
## 사전 조건
@ -47,7 +48,8 @@ description: 명시된 workspace에 계획 상태의 Milestone을 현재 작업
python3 agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_workspace.py \
--repo "$REPO" \
--milestone "$MILESTONE" \
--workspace "$WORKSPACE"
--workspace "$WORKSPACE" \
--epics "$EPICS"
```
- 기본값은 `codex / gpt-5.6-sol / xhigh`다. 다른 provider를 지정하면 모델을 별도로 주지 않는 한 해당 provider의 CLI 기본 모델을 사용한다.
@ -57,17 +59,26 @@ python3 agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_work
- 기존 branch/worktree는 정확히 같은 branch·경로이고 clean할 때만 재개한다.
- remote branch 생성 뒤 후속 단계가 실패해도 branch/worktree를 자동 삭제하지 않는다.
3. **Epic 사이클로 handoff한다**
- `WORKSPACE_READY` event에서만 대상 workspace로 이동한다.
- target Epic을 명시했으면 그대로, 없으면 Milestone 문서 순서상 첫 미완료 Epic 하나를 선택한다.
- 같은 caller turn에서 `prepare-epic-work-items`를 정확히 한 번 시작하고 그 foreground 실행을 기다린다.
- `USER_REVIEW`, 복구 불가능한 `FAILED`, `EPIC_WORK_ITEMS_READY`, `EPIC_COMPLETED` 외 routine event에는 caller가 개입하지 않는다.
3. **선택 Epic batch를 준비한다**
- `WORKSPACE_READY` 뒤 스크립트가 선택 Epic을 문서 순서대로 하나씩 `prepare-epic-work-items`에 전달한다.
- 각 Epic은 별도 fresh one-shot context를 사용한다. 앞선 Epic의 `EPIC_WORK_ITEMS_READY` plan은 같은 batch의 다음 Epic 준비 동안 유지한다.
- 두 개 이상이면 마지막 Epic 준비 뒤 fresh cross-Epic review를 한 번 수행해 scope union, dependency, write set, metadata, routing과 검증을 교정하고 push한다.
- `USER_REVIEW`, `AGENT_RECOVERY_REQUIRED`, `AGENT_TRACKING`, `FAILED`가 발생하면 batch를 멈추고 dispatcher를 시작하지 않는다.
4. **전체 준비 배리어 뒤 dispatcher로 전환한다**
- 모든 선택 Epic이 `EPIC_WORK_ITEMS_READY` 또는 `EPIC_COMPLETED`이고 deterministic batch validation과 모든 push가 끝난 경우에만 `MILESTONE_WORK_ITEMS_READY`를 낸다.
- active plan이 있으면 private/project/common 우선순위로 `orchestrate-agent-task-loop` dispatcher를 선택하고 같은 task group `m-<milestone-slug>``--dry-run`을 먼저 실행한 뒤 live를 정확히 한 번 시작한다.
- 모든 선택 Epic이 `EPIC_COMPLETED`이면 dispatcher를 생략한다.
- foreground dispatcher가 종료될 때까지 caller는 timer polling이나 상태 파일 검사를 하지 않는다. batch/dispatcher PID와 start token은 git common dir 상태에 기록해 재진입 중복 실행을 막는다.
## lifecycle event
- `PREFLIGHT_READY`: mutation 전 검증 완료
- `FEATURE_BRANCH_PUSHED`: remote feature branch 준비 완료
- `WORKSPACE_READY`: worktree와 local current 준비 완료
- `MILESTONE_WORK_ITEMS_READY`: 선택 Epic 전체 준비와 cross-Epic 검증·push 완료
- `DISPATCHER_DRY_RUN_FINISHED`, `DISPATCHER_STARTED`, `DISPATCHER_TRACKING`, `DISPATCHER_BLOCKED`
- `MILESTONE_PREPARATION_COMPLETED`: 선택 Epic 구현과 workstate sync까지 완료, 또는 all-direct라 dispatcher 생략
- `USER_REVIEW`: 사전 정합화 또는 연결된 Epic 준비에서 사용자 결정이 필요해 정상 정지
- `FAILED`: 안전하게 계속할 수 없음
@ -77,6 +88,9 @@ python3 agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_work
- [ ] branch가 remote에 존재하고 upstream이 연결됐는가
- [ ] 요청 workspace가 정확히 해당 branch의 clean worktree인가
- [ ] workspace-local `agent-roadmap/current.md`가 대상 Milestone만 가리키는가
- [ ] 선택 Epic의 Task id 합집합 밖 active pair가 없는가
- [ ] 복수 Epic이면 cross-Epic review와 push가 완료됐는가
- [ ] dispatcher dry-run이 live보다 먼저이고 `MILESTONE_WORK_ITEMS_READY` 전에는 live가 시작되지 않았는가
- [ ] state와 locator가 repository 문서가 아니라 git common dir 아래에 있는가
- 검증 실패 시: branch/worktree를 삭제하지 않고 `FAILED` event와 복구 조건을 보고한다.
@ -87,8 +101,9 @@ Milestone workspace preparation
- milestone: <path>
- branch: <feature/milestone-slug>
- workspace: <absolute path>
- event: <WORKSPACE_READY | USER_REVIEW | FAILED>
- next: <prepare-epic-work-items target | user action | recovery condition>
- epics: <selected Epic ids in document order>
- event: <MILESTONE_PREPARATION_COMPLETED | USER_REVIEW | AGENT_RECOVERY_REQUIRED | DISPATCHER_BLOCKED | FAILED>
- next: <completed | user action | recovery condition>
```
## 금지 사항
@ -96,5 +111,7 @@ Milestone workspace preparation
- `[계획]`이 아니거나 잠긴 Milestone의 branch를 만들지 않는다.
- consistency check의 `refresh-required``ready`로 간주하지 않는다.
- 사용자 소유 변경이 있는 develop checkout이나 기존 workspace를 덮어쓰지 않는다.
- 선택 Epic 중 하나라도 attention/terminal failure 상태면 dispatcher를 시작하지 않는다.
- 복수 Epic batch에서 개별 `EPIC_WORK_ITEMS_READY`만 보고 dispatcher를 먼저 시작하지 않는다.
- `git push --force`, destructive rollback, branch/worktree 자동 삭제를 하지 않는다.
- caller LLM이 주기적으로 process나 상태 파일을 모니터링하지 않는다.

View file

@ -1,4 +1,4 @@
interface:
display_name: "Prepare Milestone Workspace"
short_description: "Prepare a Git Flow workspace for one Milestone"
default_prompt: "Use $prepare-milestone-workspace to prepare the named Milestone in the explicitly provided workspace and start its first Epic."
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."

View file

@ -12,7 +12,7 @@ import re
import shutil
import subprocess
import sys
from typing import Any, Iterable
from typing import Any, Iterable, NamedTuple
VALID_AGENTS = {"codex", "claude", "gemini", "pi"}
@ -23,12 +23,24 @@ DEFAULT_REASONING_EFFORT = "xhigh"
MILESTONE_PATTERN = re.compile(
r"^agent-roadmap/phase/(?P<phase>[a-z0-9-]+)/milestones/(?P<slug>[a-z0-9-]+)\.md$"
)
EPIC_HEADING = re.compile(r"^### Epic:\s*\[(?P<id>[a-z0-9-]+)\]\s*(?P<title>.+?)\s*$")
TASK_LINE = re.compile(r"^- \[(?P<done>[ xX])\] \[(?P<id>[a-z0-9-]+)\]\s+(?P<body>.+)$")
EPIC_RANGE = re.compile(r"^(?P<start>[1-9][0-9]*)\.\.(?P<end>[1-9][0-9]*)$")
SENSITIVE_PARTS = {".env", "secret", "secrets", "credential", "credentials", "password", "passwords"}
SENSITIVE_SUFFIXES = {".pem", ".key", ".p12"}
class PreparationError(RuntimeError):
"""A fail-closed preparation error."""
class Epic(NamedTuple):
epic_id: str
title: str
task_ids: tuple[str, ...]
incomplete_ids: tuple[str, ...]
def emit(event: str, **payload: Any) -> None:
print(json.dumps({"event": event, **payload}, ensure_ascii=False, sort_keys=True), flush=True)
@ -118,6 +130,76 @@ def milestone_contract(path: Path) -> dict[str, str]:
}
def parse_epics(text: str) -> list[Epic]:
lines = text.splitlines()
starts: list[tuple[int, re.Match[str]]] = []
for index, line in enumerate(lines):
match = EPIC_HEADING.fullmatch(line)
if match:
starts.append((index, match))
epics: list[Epic] = []
for position, (start, match) in enumerate(starts):
end = starts[position + 1][0] if position + 1 < len(starts) else len(lines)
task_matches = [
value
for value in (TASK_LINE.fullmatch(line) for line in lines[start + 1 : end])
if value is not None
]
epics.append(
Epic(
epic_id=match.group("id"),
title=match.group("title"),
task_ids=tuple(value.group("id") for value in task_matches),
incomplete_ids=tuple(
value.group("id") for value in task_matches if value.group("done") == " "
),
)
)
return epics
def select_epics(epics: list[Epic], selector: str) -> list[Epic]:
if not epics:
raise PreparationError("target Milestone has no Epic")
if selector == "first-incomplete":
selected = next((epic for epic in epics if epic.incomplete_ids), None)
if selected is None:
raise PreparationError("target Milestone has no incomplete Epic")
return [selected]
range_match = EPIC_RANGE.fullmatch(selector)
if range_match:
start = int(range_match.group("start"))
end = int(range_match.group("end"))
if start > end:
raise PreparationError(f"Epic range start must not exceed end: {selector}")
if end > len(epics):
raise PreparationError(
f"Epic range exceeds document order: requested={selector} available={len(epics)}"
)
return epics[start - 1 : end]
selected: list[Epic] = []
for raw in selector.split(","):
value = raw.strip()
if not value:
raise PreparationError("--epics contains an empty selector")
matches = [
epic
for epic in epics
if epic.epic_id == value or epic.title.casefold().strip() == value.casefold()
]
if len(matches) != 1:
raise PreparationError(f"Epic selector must resolve exactly once: {value}")
if matches[0] in selected:
raise PreparationError(f"duplicate Epic selector: {value}")
selected.append(matches[0])
return [epic for epic in epics if epic in selected]
def resolve_workspace(repo: Path, raw: str) -> Path:
candidate = Path(raw).expanduser()
return (repo / candidate).resolve() if not candidate.is_absolute() else candidate.resolve()
def phase_contract(repo: Path, phase_slug: str) -> dict[str, str]:
path = repo / "agent-roadmap" / "phase" / phase_slug / "PHASE.md"
if not path.is_file():
@ -143,6 +225,78 @@ def atomic_json(path: Path, value: dict[str, Any]) -> None:
os.replace(temporary, path)
def read_json(path: Path) -> dict[str, Any] | None:
if not path.exists():
return None
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise PreparationError(f"invalid state file: {path}: {exc}") from exc
if not isinstance(value, dict):
raise PreparationError(f"state file must contain an object: {path}")
return value
def process_start_token(pid: int) -> str | None:
stat = Path(f"/proc/{pid}/stat")
try:
remainder = stat.read_text(encoding="utf-8").rsplit(")", 1)[1].split()
return f"proc:{remainder[19]}"
except (OSError, IndexError):
return None
def process_is_same(pid: object, expected_token: object) -> bool:
if not isinstance(pid, int) or pid <= 0:
return False
try:
os.kill(pid, 0)
except (OSError, ValueError):
return False
actual_token = process_start_token(pid)
if expected_token is None or actual_token is None:
return True
return actual_token == expected_token
def changed_paths(workspace: Path) -> list[str]:
unmerged = git(workspace, "diff", "--name-only", "--diff-filter=U")
if unmerged:
raise PreparationError(f"workspace has unmerged paths: {','.join(unmerged.splitlines())}")
values: set[str] = set()
for arguments in (
("diff", "--name-only", "--no-renames", "-z", "HEAD"),
("ls-files", "--others", "--exclude-standard", "-z"),
):
result = subprocess.run(
["git", *arguments],
cwd=workspace,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if result.returncode != 0:
raise PreparationError(result.stderr.decode("utf-8", "replace").strip())
values.update(
part.decode("utf-8", "surrogateescape")
for part in result.stdout.split(b"\0")
if part
)
return sorted(values)
def sensitive(path: str) -> bool:
candidate = Path(path)
lowered = {part.casefold() for part in candidate.parts}
if lowered & SENSITIVE_PARTS:
return True
name = candidate.name.casefold()
return (
any(token in name for token in ("secret", "credential", "password"))
or candidate.suffix.casefold() in SENSITIVE_SUFFIXES
)
def ref_exists(repo: Path, ref: str) -> bool:
return run(["git", "show-ref", "--verify", "--quiet", ref], cwd=repo, check=False).returncode == 0
@ -246,6 +400,583 @@ def render_current(
"""
def epic_cycle_script(workspace: Path) -> Path:
path = (
workspace
/ "agent-ops"
/ "skills"
/ "common"
/ "prepare-epic-work-items"
/ "scripts"
/ "run_epic_cycle.py"
)
if not path.is_file():
raise PreparationError(f"Epic cycle script not found: {path}")
return path
def dispatcher_script(workspace: Path) -> Path:
project = workspace / "agent-ops" / "skills" / "project" / "orchestrate-agent-task-loop"
private = workspace / "agent-ops" / "skills" / "private" / "orchestrate-agent-task-loop"
if project.is_dir() and private.is_dir():
root = private
elif project.is_dir():
root = project
else:
root = workspace / "agent-ops" / "skills" / "common" / "orchestrate-agent-task-loop"
path = root / "scripts" / "dispatch.py"
if not path.is_file():
raise PreparationError(f"dispatcher script not found: {path}")
return path
def epic_cycle_command(
*,
args: argparse.Namespace,
workspace: Path,
milestone: Path,
epic: Epic,
batch_ids: list[str],
validate_only: bool = False,
) -> list[str]:
command = [
sys.executable,
str(epic_cycle_script(workspace)),
"--workspace",
str(workspace),
"--milestone",
str(milestone),
"--epic",
epic.epic_id,
"--planner-agent",
args.planner_agent,
"--batch-task-ids",
",".join(batch_ids),
]
if args.review_agent:
command.extend(["--review-agent", args.review_agent])
if args.planner_model:
command.extend(["--planner-model", args.planner_model])
if args.review_model:
command.extend(["--review-model", args.review_model])
if args.reasoning_effort:
command.extend(["--reasoning-effort", args.reasoning_effort])
if args.pi_provider:
command.extend(["--pi-provider", args.pi_provider])
if validate_only:
command.append("--validate-only")
elif args.retry:
command.append("--retry")
return command
def validate_batch(
*,
args: argparse.Namespace,
workspace: Path,
milestone: Path,
epics: list[Epic],
batch_ids: list[str],
) -> None:
for epic in epics:
result = run(
epic_cycle_command(
args=args,
workspace=workspace,
milestone=milestone,
epic=epic,
batch_ids=batch_ids,
validate_only=True,
),
cwd=workspace,
check=False,
capture=False,
)
if result.returncode != 0:
raise PreparationError(
f"selected Epic batch validation failed: epic={epic.epic_id} exit={result.returncode}"
)
def publish_batch_review(workspace: Path) -> str:
paths = changed_paths(workspace)
if paths:
unsafe = [path for path in paths if sensitive(path)]
if unsafe:
raise PreparationError(f"sensitive path refused: {','.join(unsafe)}")
for path in paths:
git(workspace, "add", "--", path)
git(workspace, "diff", "--cached", "--check")
git(workspace, "commit", "-m", "chore(milestone): 복수 Epic 준비 결과를 검증한다")
git(workspace, "push")
if changed_paths(workspace):
raise PreparationError("workspace is dirty after batch review publish")
return git(workspace, "rev-parse", "HEAD")
def cross_epic_review(
*,
args: argparse.Namespace,
workspace: Path,
milestone: Path,
milestone_slug: str,
phase_slug: str,
epics: list[Epic],
batch_ids: list[str],
common: Path,
) -> tuple[int, dict[str, Any] | None]:
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
)
state_root = common / "milestone-work-preparation" / milestone_slug
prompt_path = state_root / "prompts" / "cross-epic-review.txt"
result_path = (
common
/ "epic-work-preparation"
/ milestone_slug
/ "_batch"
/ "cross-epic-review.json"
)
label = f"{milestone_slug}-cross-epic-review"
selected = "\n".join(
f"- [{epic.epic_id}] {epic.title}: {','.join(epic.task_ids)}" for epic in epics
)
prompt = f"""You are a fresh child agent launched for one bounded cross-Epic preparation review.
Work only in {workspace}.
Read the repository AGENTS.md completely, then read agent-ops/skills/common/router.md and only the plan/refine skills required to correct artifacts.
Do not start subagents, orchestration dispatchers, preparation scripts, or monitoring loops.
Do not commit or push; the parent runtime owns the checkpoint.
Target Milestone: {milestone}
Selected Epics in document order:
{selected}
Allowed Milestone Task ids: {','.join(batch_ids)}
Review the complete prepared artifact union across these Epics from a fresh context. Check scope coverage, cross-Epic assumptions and dependencies, write-set collisions, PLAN/CODE_REVIEW pairing and first-line metadata, task ids, indices, routing, and verification. Fix every material defect through the owning plan/refine procedure without adding an official code-review verdict. Do not implement active PLAN work. If a genuine product or scope decision is required, create the Milestone SDD USER_REVIEW artifact and stop without inventing a decision. Final in Korean.
"""
prompt_path.parent.mkdir(parents=True, exist_ok=True)
prompt_path.write_text(prompt, encoding="utf-8")
previous = read_json(result_path)
if previous is not None:
if previous.get("workspace") != str(workspace) or previous.get("label") != label:
raise PreparationError(f"cross-Epic review result identity mismatch: {result_path}")
if previous.get("status") == "succeeded":
emit(
"AGENT_RESULT_RECOVERED",
stage="cross-epic-review",
locator=previous.get("locator"),
result="succeeded",
)
elif previous.get("status") in {"running", "tracking"} and process_is_same(
previous.get("agent_pid"), previous.get("agent_process_start_token")
):
emit(
"AGENT_TRACKING",
stage="cross-epic-review",
locator=previous.get("locator"),
pid=previous.get("agent_pid"),
)
return 3, previous
elif not args.retry:
emit(
"AGENT_RECOVERY_REQUIRED",
stage="cross-epic-review",
locator=previous.get("locator"),
action="inspect locator, then rerun with --retry",
)
return 3, previous
record = previous
if record is None or record.get("status") != "succeeded":
runner = (
workspace
/ "agent-ops"
/ "skills"
/ "common"
/ "prepare-epic-work-items"
/ "scripts"
/ "run_agent_once.py"
)
command = [
sys.executable,
str(runner),
"--agent",
reviewer_agent,
"--workspace",
str(workspace),
"--prompt-file",
str(prompt_path),
"--label",
label,
"--result-file",
str(result_path),
]
if reviewer_model:
command.extend(["--model", reviewer_model])
if args.reasoning_effort:
command.extend(["--reasoning-effort", args.reasoning_effort])
if reviewer_agent == "pi" and args.pi_provider:
command.extend(["--pi-provider", args.pi_provider])
starting_head = git(workspace, "rev-parse", "HEAD")
result = run(command, cwd=workspace, check=False, capture=False)
if git(workspace, "rev-parse", "HEAD") != starting_head:
raise PreparationError("cross-Epic reviewer committed unexpectedly")
record = read_json(result_path)
if result.returncode != 0 or record is None or record.get("status") != "succeeded":
return (3 if result.returncode == 3 else 2), record
review_path = (
workspace
/ "agent-roadmap"
/ "sdd"
/ phase_slug
/ milestone_slug
/ "USER_REVIEW.md"
)
if review_path.exists():
head = publish_batch_review(workspace)
emit("USER_REVIEW", path=str(review_path), head=head)
return 2, record
task_group = workspace / "agent-task" / f"m-{milestone_slug}"
if list(task_group.glob("USER_REVIEW.md")) + list(task_group.glob("*/USER_REVIEW.md")):
raise PreparationError("cross-Epic reviewer created forbidden agent-task USER_REVIEW")
validate_batch(
args=args,
workspace=workspace,
milestone=milestone,
epics=epics,
batch_ids=batch_ids,
)
head = publish_batch_review(workspace)
emit("CROSS_EPIC_REVIEW_FINISHED", head=head, epics=[epic.epic_id for epic in epics])
return 0, record
def coordinate_batch(
*,
args: argparse.Namespace,
workspace: Path,
milestone: Path,
milestone_slug: str,
phase_slug: str,
common: Path,
) -> int:
selected = select_epics(parse_epics(milestone.read_text(encoding="utf-8")), args.epics)
empty = [epic.epic_id for epic in selected if not epic.task_ids]
if empty:
raise PreparationError(f"selected Epic has no Task ids: {','.join(empty)}")
batch_ids = [task_id for epic in selected for task_id in epic.task_ids]
duplicates = sorted({value for value in batch_ids if batch_ids.count(value) > 1})
if duplicates:
raise PreparationError(f"selected Epic Task ids are not unique: {','.join(duplicates)}")
identity = {
"milestone": str(milestone),
"workspace": str(workspace),
"selected_epics": [epic.epic_id for epic in selected],
"batch_task_ids": batch_ids,
}
state_path = common / "milestone-work-preparation" / milestone_slug / "batch-state.json"
state = read_json(state_path)
if state is None:
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)
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}"
)
if state.get("status") == "completed":
emit(
"MILESTONE_PREPARATION_COMPLETED",
milestone=milestone_slug,
epics=identity["selected_epics"],
resumed=True,
head=state.get("head"),
)
return 0
resume_blocked_dispatcher = state.get("status") == "dispatcher-blocked"
if state.get("status") == "dispatching" and process_is_same(
state.get("dispatcher_pid"), state.get("dispatcher_process_start_token")
):
emit(
"DISPATCHER_TRACKING",
pid=state.get("dispatcher_pid"),
task_group=f"m-{milestone_slug}",
)
return 3
if state.get("status") in {"dispatching", "dispatcher-tracking"} and not args.retry:
emit(
"DISPATCHER_RECOVERY_REQUIRED",
task_group=f"m-{milestone_slug}",
action="inspect dispatcher state, then rerun with --retry",
)
return 3
if state.get("status") == "dispatcher-blocked" and not args.retry:
emit(
"DISPATCHER_BLOCKED",
task_group=f"m-{milestone_slug}",
exit_code=state.get("dispatcher_exit_code"),
resumed=True,
)
return 2
epic_events = state.get("epic_events")
if not isinstance(epic_events, dict):
raise PreparationError(f"invalid Epic event map: {state_path}")
unknown_events = sorted(set(epic_events) - set(identity["selected_epics"]))
if unknown_events:
raise PreparationError(f"Epic event map contains unselected ids: {','.join(unknown_events)}")
for epic in selected:
if epic_events.get(epic.epic_id) in {"EPIC_WORK_ITEMS_READY", "EPIC_COMPLETED"}:
continue
state.update(status="epic-preparing", current_epic=epic.epic_id)
atomic_json(state_path, state)
result = run(
epic_cycle_command(
args=args,
workspace=workspace,
milestone=milestone,
epic=epic,
batch_ids=batch_ids,
),
cwd=workspace,
check=False,
capture=False,
)
epic_state_path = (
common
/ "epic-work-preparation"
/ milestone_slug
/ epic.epic_id
/ "state.json"
)
epic_state = read_json(epic_state_path) or {}
terminal = epic_state.get("event")
if result.returncode != 0 or terminal not in {"EPIC_WORK_ITEMS_READY", "EPIC_COMPLETED"}:
status = epic_state.get("status")
if terminal == "USER_REVIEW" or status == "user-review":
state.update(status="user-review", event="USER_REVIEW", current_epic=epic.epic_id)
atomic_json(state_path, state)
return 2
if result.returncode == 3 or status == "tracking":
state.update(status="epic-tracking", event="AGENT_TRACKING", current_epic=epic.epic_id)
atomic_json(state_path, state)
return 3
state.update(
status="failed",
event="FAILED",
current_epic=epic.epic_id,
reason=epic_state.get("reason", f"Epic cycle exit={result.returncode}"),
)
atomic_json(state_path, state)
return 2
epic_events[epic.epic_id] = terminal
state.update(status="active", epic_events=epic_events)
state.pop("current_epic", None)
atomic_json(state_path, state)
if len(selected) > 1 and not state.get("cross_epic_review_done"):
state.update(status="cross-epic-review")
atomic_json(state_path, state)
result, record = cross_epic_review(
args=args,
workspace=workspace,
milestone=milestone,
milestone_slug=milestone_slug,
phase_slug=phase_slug,
epics=selected,
batch_ids=batch_ids,
common=common,
)
if result != 0:
review_path = (
workspace
/ "agent-roadmap"
/ "sdd"
/ phase_slug
/ milestone_slug
/ "USER_REVIEW.md"
)
user_review = result == 2 and review_path.exists()
state.update(
status=(
"user-review"
if user_review
else "cross-epic-review-tracking"
if result == 3
else "failed"
),
event=(
"USER_REVIEW"
if user_review
else "AGENT_TRACKING"
if result == 3
else "FAILED"
),
review_locator=record.get("locator") if record else None,
)
atomic_json(state_path, state)
return result
state.update(
status="active",
cross_epic_review_done=True,
review_locator=record.get("locator") if record else None,
)
atomic_json(state_path, state)
validate_batch(
args=args,
workspace=workspace,
milestone=milestone,
epics=selected,
batch_ids=batch_ids,
)
refreshed_epics = {
epic.epic_id: epic for epic in parse_epics(milestone.read_text(encoding="utf-8"))
}
for epic_id in identity["selected_epics"]:
refreshed = refreshed_epics.get(epic_id)
if refreshed is None:
raise PreparationError(f"selected Epic disappeared before batch barrier: {epic_id}")
if not refreshed.incomplete_ids:
epic_events[epic_id] = "EPIC_COMPLETED"
state.update(
status="batch-ready",
event="MILESTONE_WORK_ITEMS_READY",
epic_events=epic_events,
)
atomic_json(state_path, state)
emit(
"MILESTONE_WORK_ITEMS_READY",
milestone=milestone_slug,
epics=[epic.epic_id for epic in selected],
terminals=epic_events,
)
if all(event == "EPIC_COMPLETED" for event in epic_events.values()):
state.update(
status="completed",
event="MILESTONE_PREPARATION_COMPLETED",
dispatcher="skipped-no-active-plans",
head=git(workspace, "rev-parse", "HEAD"),
)
atomic_json(state_path, state)
emit(
"MILESTONE_PREPARATION_COMPLETED",
milestone=milestone_slug,
epics=[epic.epic_id for epic in selected],
dispatcher="skipped-no-active-plans",
)
return 0
dispatcher = dispatcher_script(workspace)
task_group = f"m-{milestone_slug}"
if not state.get("dispatcher_dry_run_done"):
dry_run = run(
[
sys.executable,
str(dispatcher),
"--workspace",
str(workspace),
"--task-group",
task_group,
"--dry-run",
],
cwd=workspace,
check=False,
capture=False,
)
if dry_run.returncode != 0:
state.update(status="failed", event="FAILED", reason=f"dispatcher dry-run exit={dry_run.returncode}")
atomic_json(state_path, state)
return 2
state.update(dispatcher_dry_run_done=True, dispatcher=str(dispatcher))
atomic_json(state_path, state)
emit("DISPATCHER_DRY_RUN_FINISHED", task_group=task_group)
command = [
sys.executable,
str(dispatcher),
"--workspace",
str(workspace),
"--task-group",
task_group,
]
if resume_blocked_dispatcher and args.retry:
command.append("--retry-blocked")
try:
process = subprocess.Popen(command, cwd=workspace, start_new_session=True)
except OSError as exc:
raise PreparationError(f"dispatcher launch failed: {exc}") from exc
state.update(
status="dispatching",
event="DISPATCHER_STARTED",
dispatcher_live_started=True,
dispatcher_pid=process.pid,
dispatcher_process_start_token=process_start_token(process.pid),
dispatcher_command=command,
)
atomic_json(state_path, state)
emit("DISPATCHER_STARTED", task_group=task_group, pid=process.pid)
try:
exit_code = process.wait()
except KeyboardInterrupt:
state.update(status="dispatcher-tracking", event="DISPATCHER_TRACKING")
atomic_json(state_path, state)
emit("DISPATCHER_TRACKING", task_group=task_group, pid=process.pid)
return 3
state.update(dispatcher_exit_code=exit_code)
if exit_code == 3:
state.update(status="dispatcher-tracking", event="DISPATCHER_TRACKING")
atomic_json(state_path, state)
emit("DISPATCHER_TRACKING", task_group=task_group, exit_code=exit_code)
return 3
if exit_code != 0:
state.update(status="dispatcher-blocked", event="DISPATCHER_BLOCKED")
atomic_json(state_path, state)
emit("DISPATCHER_BLOCKED", task_group=task_group, exit_code=exit_code)
return 2
validate_batch(
args=args,
workspace=workspace,
milestone=milestone,
epics=selected,
batch_ids=batch_ids,
)
refreshed = {epic.epic_id: epic for epic in parse_epics(milestone.read_text(encoding="utf-8"))}
incomplete = [
epic_id
for epic_id in identity["selected_epics"]
if epic_id not in refreshed or refreshed[epic_id].incomplete_ids
]
if incomplete:
raise PreparationError(
"dispatcher exited successfully but selected Epic workstate is incomplete: "
+ ",".join(incomplete)
)
state.update(
status="completed",
event="MILESTONE_PREPARATION_COMPLETED",
head=git(workspace, "rev-parse", "HEAD"),
)
atomic_json(state_path, state)
emit(
"MILESTONE_PREPARATION_COMPLETED",
milestone=milestone_slug,
epics=identity["selected_epics"],
dispatcher_exit_code=exit_code,
head=state["head"],
)
return 0
def parser() -> argparse.ArgumentParser:
value = argparse.ArgumentParser(description=__doc__)
value.add_argument("--repo", required=True)
@ -261,6 +992,15 @@ def parser() -> argparse.ArgumentParser:
value.add_argument("--review-model")
value.add_argument("--reasoning-effort", default=DEFAULT_REASONING_EFFORT)
value.add_argument("--pi-provider")
value.add_argument(
"--epics",
help="prepare and dispatch one/list/range selector; use first-incomplete or 1..N",
)
value.add_argument(
"--retry",
action="store_true",
help="resume a stopped batch after its recorded recovery condition was handled",
)
value.add_argument("--remote", default="origin")
value.add_argument(
"--skip-agent-probe",
@ -293,7 +1033,7 @@ def prepare(args: argparse.Namespace) -> int:
phase_slug = milestone_match.group("phase")
milestone_slug = milestone_match.group("slug")
phase = phase_contract(repo, phase_slug)
workspace = Path(args.workspace).expanduser().resolve()
workspace = resolve_workspace(repo, args.workspace)
if workspace == repo:
raise PreparationError("feature workspace must differ from the develop checkout")
try:
@ -314,6 +1054,8 @@ def prepare(args: argparse.Namespace) -> int:
common = git_common_dir(repo)
state_root = common / "milestone-work-preparation" / milestone_slug
state_path = state_root / "workspace-state.json"
batch_state_path = state_root / "batch-state.json"
target_milestone_path = workspace / milestone_path.relative_to(repo)
state_root.mkdir(parents=True, exist_ok=True)
lock_path = state_root / "workspace.lock"
with lock_path.open("a+", encoding="utf-8") as lock:
@ -382,6 +1124,13 @@ def prepare(args: argparse.Namespace) -> int:
local_exists = ref_exists(repo, local_ref)
remote_exists = ref_exists(repo, remote_ref)
prior_batch = read_json(batch_state_path) if args.epics else None
resuming_batch = bool(
prior_batch
and prior_batch.get("status") != "completed"
and prior_batch.get("workspace") == str(workspace)
and prior_batch.get("milestone") == str(target_milestone_path)
)
if not local_exists:
if remote_exists:
git(repo, "branch", "--track", branch, f"{args.remote}/{branch}")
@ -394,7 +1143,12 @@ def prepare(args: argparse.Namespace) -> int:
).returncode != 0:
raise PreparationError(f"feature branch does not contain current {args.remote}/{develop}")
if remote_exists and git(repo, "rev-parse", local_ref) != git(repo, "rev-parse", remote_ref):
raise PreparationError("existing local and remote feature branches differ")
if not resuming_batch or run(
["git", "merge-base", "--is-ancestor", remote_ref, local_ref],
cwd=repo,
check=False,
).returncode != 0:
raise PreparationError("existing local and remote feature branches differ")
if not remote_exists:
git(repo, "push", "--set-upstream", args.remote, branch)
else:
@ -402,7 +1156,8 @@ def prepare(args: argparse.Namespace) -> int:
emit("FEATURE_BRANCH_PUSHED", branch=branch, remote=args.remote)
if branch_worktrees:
ensure_clean(workspace, "feature workspace")
if not resuming_batch:
ensure_clean(workspace, "feature workspace")
else:
if workspace.exists() and any(workspace.iterdir()):
raise PreparationError(f"workspace exists and is not empty: {workspace}")
@ -421,8 +1176,8 @@ def prepare(args: argparse.Namespace) -> int:
),
encoding="utf-8",
)
ensure_clean(workspace, "feature workspace")
target_milestone_path = workspace / milestone_path.relative_to(repo)
if not resuming_batch:
ensure_clean(workspace, "feature workspace")
state = {
"status": "workspace-ready",
"milestone": str(target_milestone_path),
@ -435,6 +1190,15 @@ def prepare(args: argparse.Namespace) -> int:
}
atomic_json(state_path, state)
emit("WORKSPACE_READY", **state)
if args.epics:
return coordinate_batch(
args=args,
workspace=workspace,
milestone=target_milestone_path,
milestone_slug=milestone_slug,
phase_slug=phase_slug,
common=common,
)
return 0

View file

@ -31,6 +31,39 @@ def command(cwd: Path, *args: str) -> str:
class PrepareWorkspaceTest(unittest.TestCase):
def test_relative_workspace_is_resolved_from_repository_root(self) -> None:
repo = Path("/tmp/example/iop")
self.assertEqual(
MODULE.resolve_workspace(repo, "../iop-s1"),
Path("/tmp/example/iop-s1"),
)
def test_epic_document_range_is_one_based_and_inclusive(self) -> None:
epics = MODULE.parse_epics(
"""## 기능
### Epic: [first] First
- [ ] [first-task] first
### Epic: [second] Second
- [ ] [second-task] second
### Epic: [third] Third
- [ ] [third-task] third
"""
)
self.assertEqual(
[epic.epic_id for epic in MODULE.select_epics(epics, "1..2")],
["first", "second"],
)
self.assertEqual(
[epic.epic_id for epic in MODULE.select_epics(epics, "second,first")],
["first", "second"],
)
def test_workspace_defaults_to_codex_top_model_and_reasoning(self) -> None:
args = MODULE.parser().parse_args(
["--repo", "/repo", "--milestone", "milestone.md", "--workspace", "/workspace"]
@ -136,6 +169,306 @@ class PrepareWorkspaceTest(unittest.TestCase):
self.assertIn("sample-milestone.md", current)
self.assertFalse(command(worktree, "git", "status", "--porcelain=v1"))
def test_two_epic_batch_opens_dispatcher_barrier_once_after_both(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)
def write_milestone(done: bool = False) -> None:
mark = "x" if done else " "
milestone.write_text(
"# Milestone: Sample\n\n## 기능\n\n"
"### Epic: [first] First\n\n"
f"- [{mark}] [first-task] first\n\n"
"### Epic: [second] Second\n\n"
f"- [{mark}] [second-task] second\n",
encoding="utf-8",
)
write_milestone()
args = MODULE.apply_defaults(
MODULE.parser().parse_args(
[
"--repo",
str(workspace),
"--milestone",
str(milestone),
"--workspace",
str(workspace),
"--epics",
"1..2",
]
)
)
order: list[str] = []
def fake_run(command: list[str], **_: object) -> subprocess.CompletedProcess[str]:
if "--validate-only" in command:
order.append("validate")
return subprocess.CompletedProcess(command, 0)
if "--epic" in command:
epic_id = command[command.index("--epic") + 1]
order.append(f"epic:{epic_id}")
state_path = common / "epic-work-preparation" / "sample" / epic_id / "state.json"
MODULE.atomic_json(
state_path,
{
"identity": f"sample:{epic_id}",
"status": "completed",
"event": "EPIC_WORK_ITEMS_READY",
},
)
return subprocess.CompletedProcess(command, 0)
if "--dry-run" in command:
order.append("dry-run")
return subprocess.CompletedProcess(command, 0)
raise AssertionError(command)
class FakeDispatcher:
pid = 999_999_991
def __init__(self, command: list[str], **_: object) -> None:
order.append("live")
def wait(self) -> int:
write_milestone(done=True)
return 0
def fake_cross_review(**_: object) -> tuple[int, dict[str, object]]:
order.append("cross-review")
return 0, {}
with (
mock.patch.object(MODULE, "epic_cycle_script", return_value=Path("/cycle.py")),
mock.patch.object(MODULE, "dispatcher_script", return_value=Path("/dispatch.py")),
mock.patch.object(MODULE, "run", side_effect=fake_run),
mock.patch.object(MODULE, "cross_epic_review", side_effect=fake_cross_review),
mock.patch.object(MODULE, "git", return_value="head"),
mock.patch.object(MODULE.subprocess, "Popen", FakeDispatcher),
):
first = MODULE.coordinate_batch(
args=args,
workspace=workspace,
milestone=milestone,
milestone_slug="sample",
phase_slug="phase-one",
common=common,
)
second = MODULE.coordinate_batch(
args=args,
workspace=workspace,
milestone=milestone,
milestone_slug="sample",
phase_slug="phase-one",
common=common,
)
self.assertEqual((first, second), (0, 0))
self.assertLess(order.index("epic:first"), order.index("epic:second"))
self.assertLess(order.index("epic:second"), order.index("cross-review"))
self.assertLess(order.index("cross-review"), order.index("dry-run"))
self.assertLess(order.index("dry-run"), order.index("live"))
self.assertEqual(order.count("live"), 1)
def test_all_direct_batch_skips_dispatcher_and_user_review_stops_batch(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- [ ] [first-task] first\n\n"
"### Epic: [second] Second\n\n- [ ] [second-task] second\n",
encoding="utf-8",
)
args = MODULE.apply_defaults(
MODULE.parser().parse_args(
[
"--repo",
str(workspace),
"--milestone",
str(milestone),
"--workspace",
str(workspace),
"--epics",
"1..2",
]
)
)
def all_direct(command: list[str], **_: object) -> subprocess.CompletedProcess[str]:
if "--validate-only" in command:
return subprocess.CompletedProcess(command, 0)
epic_id = command[command.index("--epic") + 1]
state_path = common / "epic-work-preparation" / "sample" / epic_id / "state.json"
MODULE.atomic_json(
state_path,
{"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=all_direct),
mock.patch.object(MODULE, "cross_epic_review", return_value=(0, {})),
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)
popen.assert_not_called()
other_common = root / "other-common"
def user_review(command: list[str], **_: object) -> subprocess.CompletedProcess[str]:
epic_id = command[command.index("--epic") + 1]
state_path = other_common / "epic-work-preparation" / "sample" / epic_id / "state.json"
MODULE.atomic_json(
state_path,
{"status": "user-review", "event": "USER_REVIEW"},
)
return subprocess.CompletedProcess(command, 2)
with (
mock.patch.object(MODULE, "epic_cycle_script", return_value=Path("/cycle.py")),
mock.patch.object(MODULE, "run", side_effect=user_review),
mock.patch.object(MODULE.subprocess, "Popen") as blocked_popen,
):
blocked = MODULE.coordinate_batch(
args=args,
workspace=workspace,
milestone=milestone,
milestone_slug="sample",
phase_slug="phase-one",
common=other_common,
)
self.assertEqual(blocked, 2)
blocked_popen.assert_not_called()
def test_live_dispatcher_state_prevents_duplicate_launch(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- [ ] [first-task] first\n",
encoding="utf-8",
)
args = MODULE.apply_defaults(
MODULE.parser().parse_args(
[
"--repo",
str(workspace),
"--milestone",
str(milestone),
"--workspace",
str(workspace),
"--epics",
"1..1",
]
)
)
state_path = common / "milestone-work-preparation" / "sample" / "batch-state.json"
MODULE.atomic_json(
state_path,
{
"milestone": str(milestone),
"workspace": str(workspace),
"selected_epics": ["first"],
"batch_task_ids": ["first-task"],
"status": "dispatching",
"epic_events": {"first": "EPIC_WORK_ITEMS_READY"},
"dispatcher_pid": os.getpid(),
"dispatcher_process_start_token": MODULE.process_start_token(os.getpid()),
},
)
with 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, 3)
popen.assert_not_called()
def test_recovered_cross_epic_review_still_validates_and_publishes(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", encoding="utf-8")
epics = [
MODULE.Epic("first", "First", ("first-task",), ("first-task",)),
MODULE.Epic("second", "Second", ("second-task",), ("second-task",)),
]
args = MODULE.apply_defaults(
MODULE.parser().parse_args(
[
"--repo",
str(workspace),
"--milestone",
str(milestone),
"--workspace",
str(workspace),
]
)
)
result_path = (
common
/ "epic-work-preparation"
/ "sample"
/ "_batch"
/ "cross-epic-review.json"
)
MODULE.atomic_json(
result_path,
{
"workspace": str(workspace),
"label": "sample-cross-epic-review",
"status": "succeeded",
"locator": str(result_path),
},
)
with (
mock.patch.object(MODULE, "validate_batch") as validate,
mock.patch.object(MODULE, "publish_batch_review", return_value="head") as publish,
mock.patch.object(MODULE, "run") as run,
):
result, _ = MODULE.cross_epic_review(
args=args,
workspace=workspace,
milestone=milestone,
milestone_slug="sample",
phase_slug="phase-one",
epics=epics,
batch_ids=["first-task", "second-task"],
common=common,
)
self.assertEqual(result, 0)
run.assert_not_called()
validate.assert_called_once()
publish.assert_called_once_with(workspace)
if __name__ == "__main__":
unittest.main()

View file

@ -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 마일스톤 작업 준비해 | `agent-ops/skills/common/prepare-milestone-workspace/SKILL.md` |
| X에 Y 작업 준비해, X 위치에 Y 작업 준비해, X에 Y 작업준비해, X에 Y 마일스톤 작업 준비해, X에 Y 작업 준비하고 Epic은 N번째까지 진행해 | `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,7 @@
라우팅 우선순위:
- `X에 Y 작업 준비해`처럼 workspace 위치와 대상 Milestone이 함께 명시되면 `prepare-milestone-workspace`를 선택하고, `WORKSPACE_READY` 뒤 정확히 한 Epic에 `prepare-epic-work-items`를 연결한다. workspace 위치가 없으면 이 흐름으로 라우팅하지 않고 확인을 요청한다.
- `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 위치가 없으면 이 흐름으로 라우팅하지 않고 확인을 요청한다.
- 한 Epic 안에서 작은 작업 직접 처리와 큰 작업 plan 작성을 함께 요청하면 `prepare-epic-work-items`를 선택한다. 이미 존재하는 plan만 세분화하는 요청과 새로운 plan만 작성하는 요청에는 이 스킬을 선택하지 않는다.
- 이미 생성된 미착수 pair의 분할만 요청하면 lane과 관계없이 `refine-plans`를 선택한다. 새 plan 작성이나 구현 범위 재분석이 포함되면 `plan`을 선택한다.
- `refine-plans` 대상이 아닌 PLAN/CODE_REVIEW 작성 또는 재작성이 요청 범위에 포함되면 `plan`을 선택한다. `plan`이 최종 단계에서 `finalize-task-routing`을 필수 호출한다.