sync: agent-ops from agentic-framework v1.1.188

This commit is contained in:
toki 2026-08-06 12:11:37 +09:00
parent 6c8b9d03bd
commit ca24eb6bc6
61 changed files with 13097 additions and 482 deletions

View file

@ -8,7 +8,7 @@
- 코드 변경 전 관련 domain rule을 먼저 확인한다.
- 요청 범위를 넘는 변경을 하지 않는다.
- 불확실하면 단정하지 말고 후보를 제시한다.
- `agent-task/archive/**`는 일반 작업에서 읽지 않는다. 예외: 사용자가 과거 작업 확인, 복원, 비교, 특정 archive 경로 확인을 요청한 경우, active `PLAN-*.md` / `CODE_REVIEW-*.md` / `USER_REVIEW.md`가 특정 archive evidence 경로를 명시한 경우, plan/code-review 루프의 split subtask 선행 의존성 충족 여부를 확인하는 경우, plan 스킬이 같은 task group의 새 index를 충돌 없이 할당하는 경우, 또는 `sync-agent-ui mode=reconcile-completion`에 exact `completion-log` 경로가 전달된 경우에만 필요한 범위를 좁게 읽는다. split 의존성 확인은 같은 task group의 후보 `complete.log`만 읽을 수 있다. index 할당은 같은 task group의 archived sibling directory basename만 열람하고 내부 파일은 읽지 않는다. UI 완료 정합화는 전달된 exact `complete.log` 한 건만 읽고 sibling archive log를 탐색하지 않는다.
- `agent-task/archive/**`는 일반 작업에서 읽지 않는다. 예외: 사용자가 과거 작업 확인, 복원, 비교, 특정 archive 경로 확인을 요청한 경우, active `PLAN-*.md` / `CODE_REVIEW-*.md` / `USER_REVIEW.md`가 특정 archive evidence 경로를 명시한 경우, plan/code-review 루프의 split subtask 선행 의존성 충족 여부를 확인하는 경우, plan 스킬이 같은 task group의 새 index를 충돌 없이 할당하는 경우, `sync-milestone-workstate`가 활성 대상 Milestone의 동일 `m-<milestone-slug>` task group evidence를 집계하는 경우, 또는 `sync-agent-ui mode=reconcile-completion`에 exact `completion-log` 경로가 전달된 경우에만 필요한 범위를 좁게 읽는다. split 의존성 확인과 Milestone workstate 동기화는 같은 task group의 후보 `complete.log`와 직접 연결된 plan/review log만 읽을 수 있다. index 할당은 같은 task group의 archived sibling directory basename만 열람하고 내부 파일은 읽지 않는다. UI 완료 정합화는 전달된 exact `complete.log` 한 건만 읽고 sibling archive log를 탐색하지 않는다.
- `agent-roadmap/` 디렉터리가 있는 프로젝트에서도 `agent-roadmap/archive/**`는 일반 작업에서 읽지 않는다. 로드맵 과거 완료 내용, 완료 근거, 복원, 비교가 필요한 경우에만 `agent-ops/rules/common/rules-roadmap.md`의 archive 접근 규칙을 따른다.
- `agent-ui/` 디렉터리가 있는 프로젝트에서도 `agent-ui/definition/archive/**`와 `agent-ui/archive/user-review/**`는 일반 작업에서 읽지 않는다. UI 과거 결정, 복원, 비교, 해결된 user review 확인이 필요한 경우에만 `agent-ops/rules/common/rules-agent-ui.md`의 archive 접근 규칙을 따른다.
- `agent-spec/` 디렉터리가 있는 프로젝트에서 현재 구현 스펙 확인, 기존 기능 변경, 완료 검토, 구현 스펙 생성/갱신 요청은 세션 1회 `agent-ops/rules/common/rules-agent-spec.md`를 읽고, `agent-spec/index.md`와 매칭되는 spec 문서만 읽는다.
@ -34,7 +34,7 @@
**아래 경우에 부합되는지 반드시 끝까지 정독해서 읽고, 부합할 경우 `agent-ops/skills/common/router.md`를 작업 최초 1회 읽고 수행한다.** 자동으로 수행하지 않는다. **절대 스킵하지 말고 정독해야한다**
- agent-ops 초기화
- domain rule 생성
- skill 생성
- skill 생성/갱신
- agent-ui 생성/갱신/검증/코드 동기화, UI 스캐폴드, 화면 정의서, view/component/frame/wireframe 정의, agent-ui USER_REVIEW
- 테스트 룰 작성/생성/수정, 도메인별/검증 시나리오별 테스트 문서, create-test/update-test
- 계약 생성/업데이트, agent-contract 생성/갱신, inner/outer 계약 문서 작성/정리, 계약 포인터 관리
@ -44,6 +44,7 @@
- 로드맵/마일스톤 생성·갱신
- SDD 작성/갱신, SDD 필요 여부, SDD gate 확인, SDD 사용자 리뷰, SDD 잠금 해제
- 로드맵 현지점 / 현재 작업 지점 확인
- 현재 또는 지정 마일스톤 시작 전 정합성 체크 / 전체 프로젝트 작업현황과 마일스톤 정합성 확인 / plan 전 마일스톤 리프레시
- 마일스톤 완료 검토 / 종료 검토 / 현재 마일스톤 닫기 / 다음 마일스톤 지정
- 계획 작성 / plan 생성
- 코드 리뷰 / review 진행

View file

@ -8,7 +8,7 @@
- 코드 변경 전 관련 domain rule을 먼저 확인한다.
- 요청 범위를 넘는 변경을 하지 않는다.
- 불확실하면 단정하지 말고 후보를 제시한다.
- `agent-task/archive/**`는 일반 작업에서 읽지 않는다. 예외: 사용자가 과거 작업 확인, 복원, 비교, 특정 archive 경로 확인을 요청한 경우, active `PLAN-*.md` / `CODE_REVIEW-*.md` / `USER_REVIEW.md`가 특정 archive evidence 경로를 명시한 경우, plan/code-review 루프의 split subtask 선행 의존성 충족 여부를 확인하는 경우, plan 스킬이 같은 task group의 새 index를 충돌 없이 할당하는 경우, 또는 `sync-agent-ui mode=reconcile-completion`에 exact `completion-log` 경로가 전달된 경우에만 필요한 범위를 좁게 읽는다. split 의존성 확인은 같은 task group의 후보 `complete.log`만 읽을 수 있다. index 할당은 같은 task group의 archived sibling directory basename만 열람하고 내부 파일은 읽지 않는다. UI 완료 정합화는 전달된 exact `complete.log` 한 건만 읽고 sibling archive log를 탐색하지 않는다.
- `agent-task/archive/**`는 일반 작업에서 읽지 않는다. 예외: 사용자가 과거 작업 확인, 복원, 비교, 특정 archive 경로 확인을 요청한 경우, active `PLAN-*.md` / `CODE_REVIEW-*.md` / `USER_REVIEW.md`가 특정 archive evidence 경로를 명시한 경우, plan/code-review 루프의 split subtask 선행 의존성 충족 여부를 확인하는 경우, plan 스킬이 같은 task group의 새 index를 충돌 없이 할당하는 경우, `sync-milestone-workstate`가 활성 대상 Milestone의 동일 `m-<milestone-slug>` task group evidence를 집계하는 경우, 또는 `sync-agent-ui mode=reconcile-completion`에 exact `completion-log` 경로가 전달된 경우에만 필요한 범위를 좁게 읽는다. split 의존성 확인과 Milestone workstate 동기화는 같은 task group의 후보 `complete.log`와 직접 연결된 plan/review log만 읽을 수 있다. index 할당은 같은 task group의 archived sibling directory basename만 열람하고 내부 파일은 읽지 않는다. UI 완료 정합화는 전달된 exact `complete.log` 한 건만 읽고 sibling archive log를 탐색하지 않는다.
- `agent-roadmap/` 디렉터리가 있는 프로젝트에서도 `agent-roadmap/archive/**`는 일반 작업에서 읽지 않는다. 로드맵 과거 완료 내용, 완료 근거, 복원, 비교가 필요한 경우에만 `agent-ops/rules/common/rules-roadmap.md`의 archive 접근 규칙을 따른다.
- `agent-ui/` 디렉터리가 있는 프로젝트에서도 `agent-ui/definition/archive/**`와 `agent-ui/archive/user-review/**`는 일반 작업에서 읽지 않는다. UI 과거 결정, 복원, 비교, 해결된 user review 확인이 필요한 경우에만 `agent-ops/rules/common/rules-agent-ui.md`의 archive 접근 규칙을 따른다.
- `agent-spec/` 디렉터리가 있는 프로젝트에서 현재 구현 스펙 확인, 기존 기능 변경, 완료 검토, 구현 스펙 생성/갱신 요청은 세션 1회 `agent-ops/rules/common/rules-agent-spec.md`를 읽고, `agent-spec/index.md`와 매칭되는 spec 문서만 읽는다.
@ -34,7 +34,7 @@
**아래 경우에 부합되는지 반드시 끝까지 정독해서 읽고, 부합할 경우 `agent-ops/skills/common/router.md`를 작업 최초 1회 읽고 수행한다.** 자동으로 수행하지 않는다. **절대 스킵하지 말고 정독해야한다**
- agent-ops 초기화
- domain rule 생성
- skill 생성
- skill 생성/갱신
- agent-ui 생성/갱신/검증/코드 동기화, UI 스캐폴드, 화면 정의서, view/component/frame/wireframe 정의, agent-ui USER_REVIEW
- 테스트 룰 작성/생성/수정, 도메인별/검증 시나리오별 테스트 문서, create-test/update-test
- 계약 생성/업데이트, agent-contract 생성/갱신, inner/outer 계약 문서 작성/정리, 계약 포인터 관리
@ -44,6 +44,7 @@
- 로드맵/마일스톤 생성·갱신
- SDD 작성/갱신, SDD 필요 여부, SDD gate 확인, SDD 사용자 리뷰, SDD 잠금 해제
- 로드맵 현지점 / 현재 작업 지점 확인
- 현재 또는 지정 마일스톤 시작 전 정합성 체크 / 전체 프로젝트 작업현황과 마일스톤 정합성 확인 / plan 전 마일스톤 리프레시
- 마일스톤 완료 검토 / 종료 검토 / 현재 마일스톤 닫기 / 다음 마일스톤 지정
- 계획 작성 / plan 생성
- 코드 리뷰 / review 진행

View file

@ -8,7 +8,7 @@
- 코드 변경 전 관련 domain rule을 먼저 확인한다.
- 요청 범위를 넘는 변경을 하지 않는다.
- 불확실하면 단정하지 말고 후보를 제시한다.
- `agent-task/archive/**`는 일반 작업에서 읽지 않는다. 예외: 사용자가 과거 작업 확인, 복원, 비교, 특정 archive 경로 확인을 요청한 경우, active `PLAN-*.md` / `CODE_REVIEW-*.md` / `USER_REVIEW.md`가 특정 archive evidence 경로를 명시한 경우, plan/code-review 루프의 split subtask 선행 의존성 충족 여부를 확인하는 경우, plan 스킬이 같은 task group의 새 index를 충돌 없이 할당하는 경우, 또는 `sync-agent-ui mode=reconcile-completion`에 exact `completion-log` 경로가 전달된 경우에만 필요한 범위를 좁게 읽는다. split 의존성 확인 같은 task group의 후보 `complete.log`만 읽을 수 있다. index 할당은 같은 task group의 archived sibling directory basename만 열람하고 내부 파일은 읽지 않는다. UI 완료 정합화는 전달된 exact `complete.log` 한 건만 읽고 sibling archive log를 탐색하지 않는다.
- `agent-task/archive/**`는 일반 작업에서 읽지 않는다. 예외: 사용자가 과거 작업 확인, 복원, 비교, 특정 archive 경로 확인을 요청한 경우, active `PLAN-*.md` / `CODE_REVIEW-*.md` / `USER_REVIEW.md`가 특정 archive evidence 경로를 명시한 경우, plan/code-review 루프의 split subtask 선행 의존성 충족 여부를 확인하는 경우, plan 스킬이 같은 task group의 새 index를 충돌 없이 할당하는 경우, `sync-milestone-workstate`가 활성 대상 Milestone의 동일 `m-<milestone-slug>` task group evidence를 집계하는 경우, 또는 `sync-agent-ui mode=reconcile-completion`에 exact `completion-log` 경로가 전달된 경우에만 필요한 범위를 좁게 읽는다. split 의존성 확인과 Milestone workstate 동기화는 같은 task group의 후보 `complete.log`와 직접 연결된 plan/review log만 읽을 수 있다. index 할당은 같은 task group의 archived sibling directory basename만 열람하고 내부 파일은 읽지 않는다. UI 완료 정합화는 전달된 exact `complete.log` 한 건만 읽고 sibling archive log를 탐색하지 않는다.
- `agent-roadmap/` 디렉터리가 있는 프로젝트에서도 `agent-roadmap/archive/**`는 일반 작업에서 읽지 않는다. 로드맵 과거 완료 내용, 완료 근거, 복원, 비교가 필요한 경우에만 `agent-ops/rules/common/rules-roadmap.md`의 archive 접근 규칙을 따른다.
- `agent-ui/` 디렉터리가 있는 프로젝트에서도 `agent-ui/definition/archive/**``agent-ui/archive/user-review/**`는 일반 작업에서 읽지 않는다. UI 과거 결정, 복원, 비교, 해결된 user review 확인이 필요한 경우에만 `agent-ops/rules/common/rules-agent-ui.md`의 archive 접근 규칙을 따른다.
- `agent-spec/` 디렉터리가 있는 프로젝트에서 현재 구현 스펙 확인, 기존 기능 변경, 완료 검토, 구현 스펙 생성/갱신 요청은 세션 1회 `agent-ops/rules/common/rules-agent-spec.md`를 읽고, `agent-spec/index.md`와 매칭되는 spec 문서만 읽는다.
@ -34,7 +34,7 @@
**아래 경우에 부합되는지 반드시 끝까지 정독해서 읽고, 부합할 경우 `agent-ops/skills/common/router.md`를 작업 최초 1회 읽고 수행한다.** 자동으로 수행하지 않는다. **절대 스킵하지 말고 정독해야한다**
- agent-ops 초기화
- domain rule 생성
- skill 생성
- skill 생성/갱신
- agent-ui 생성/갱신/검증/코드 동기화, UI 스캐폴드, 화면 정의서, view/component/frame/wireframe 정의, agent-ui USER_REVIEW
- 테스트 룰 작성/생성/수정, 도메인별/검증 시나리오별 테스트 문서, create-test/update-test
- 계약 생성/업데이트, agent-contract 생성/갱신, inner/outer 계약 문서 작성/정리, 계약 포인터 관리
@ -44,6 +44,7 @@
- 로드맵/마일스톤 생성·갱신
- SDD 작성/갱신, SDD 필요 여부, SDD gate 확인, SDD 사용자 리뷰, SDD 잠금 해제
- 로드맵 현지점 / 현재 작업 지점 확인
- 현재 또는 지정 마일스톤 시작 전 정합성 체크 / 전체 프로젝트 작업현황과 마일스톤 정합성 확인 / plan 전 마일스톤 리프레시
- 마일스톤 완료 검토 / 종료 검토 / 현재 마일스톤 닫기 / 다음 마일스톤 지정
- 계획 작성 / plan 생성
- 코드 리뷰 / review 진행

View file

@ -8,7 +8,7 @@
- 코드 변경 전 관련 domain rule을 먼저 확인한다.
- 요청 범위를 넘는 변경을 하지 않는다.
- 불확실하면 단정하지 말고 후보를 제시한다.
- `agent-task/archive/**`는 일반 작업에서 읽지 않는다. 예외: 사용자가 과거 작업 확인, 복원, 비교, 특정 archive 경로 확인을 요청한 경우, active `PLAN-*.md` / `CODE_REVIEW-*.md` / `USER_REVIEW.md`가 특정 archive evidence 경로를 명시한 경우, plan/code-review 루프의 split subtask 선행 의존성 충족 여부를 확인하는 경우, plan 스킬이 같은 task group의 새 index를 충돌 없이 할당하는 경우, 또는 `sync-agent-ui mode=reconcile-completion`에 exact `completion-log` 경로가 전달된 경우에만 필요한 범위를 좁게 읽는다. split 의존성 확인 같은 task group의 후보 `complete.log`만 읽을 수 있다. index 할당은 같은 task group의 archived sibling directory basename만 열람하고 내부 파일은 읽지 않는다. UI 완료 정합화는 전달된 exact `complete.log` 한 건만 읽고 sibling archive log를 탐색하지 않는다.
- `agent-task/archive/**`는 일반 작업에서 읽지 않는다. 예외: 사용자가 과거 작업 확인, 복원, 비교, 특정 archive 경로 확인을 요청한 경우, active `PLAN-*.md` / `CODE_REVIEW-*.md` / `USER_REVIEW.md`가 특정 archive evidence 경로를 명시한 경우, plan/code-review 루프의 split subtask 선행 의존성 충족 여부를 확인하는 경우, plan 스킬이 같은 task group의 새 index를 충돌 없이 할당하는 경우, `sync-milestone-workstate`가 활성 대상 Milestone의 동일 `m-<milestone-slug>` task group evidence를 집계하는 경우, 또는 `sync-agent-ui mode=reconcile-completion`에 exact `completion-log` 경로가 전달된 경우에만 필요한 범위를 좁게 읽는다. split 의존성 확인과 Milestone workstate 동기화는 같은 task group의 후보 `complete.log`와 직접 연결된 plan/review log만 읽을 수 있다. index 할당은 같은 task group의 archived sibling directory basename만 열람하고 내부 파일은 읽지 않는다. UI 완료 정합화는 전달된 exact `complete.log` 한 건만 읽고 sibling archive log를 탐색하지 않는다.
- `agent-roadmap/` 디렉터리가 있는 프로젝트에서도 `agent-roadmap/archive/**`는 일반 작업에서 읽지 않는다. 로드맵 과거 완료 내용, 완료 근거, 복원, 비교가 필요한 경우에만 `agent-ops/rules/common/rules-roadmap.md`의 archive 접근 규칙을 따른다.
- `agent-ui/` 디렉터리가 있는 프로젝트에서도 `agent-ui/definition/archive/**``agent-ui/archive/user-review/**`는 일반 작업에서 읽지 않는다. UI 과거 결정, 복원, 비교, 해결된 user review 확인이 필요한 경우에만 `agent-ops/rules/common/rules-agent-ui.md`의 archive 접근 규칙을 따른다.
- `agent-spec/` 디렉터리가 있는 프로젝트에서 현재 구현 스펙 확인, 기존 기능 변경, 완료 검토, 구현 스펙 생성/갱신 요청은 세션 1회 `agent-ops/rules/common/rules-agent-spec.md`를 읽고, `agent-spec/index.md`와 매칭되는 spec 문서만 읽는다.
@ -34,7 +34,7 @@
**아래 경우에 부합되는지 반드시 끝까지 정독해서 읽고, 부합할 경우 `agent-ops/skills/common/router.md`를 작업 최초 1회 읽고 수행한다.** 자동으로 수행하지 않는다. **절대 스킵하지 말고 정독해야한다**
- agent-ops 초기화
- domain rule 생성
- skill 생성
- skill 생성/갱신
- agent-ui 생성/갱신/검증/코드 동기화, UI 스캐폴드, 화면 정의서, view/component/frame/wireframe 정의, agent-ui USER_REVIEW
- 테스트 룰 작성/생성/수정, 도메인별/검증 시나리오별 테스트 문서, create-test/update-test
- 계약 생성/업데이트, agent-contract 생성/갱신, inner/outer 계약 문서 작성/정리, 계약 포인터 관리
@ -44,6 +44,7 @@
- 로드맵/마일스톤 생성·갱신
- SDD 작성/갱신, SDD 필요 여부, SDD gate 확인, SDD 사용자 리뷰, SDD 잠금 해제
- 로드맵 현지점 / 현재 작업 지점 확인
- 현재 또는 지정 마일스톤 시작 전 정합성 체크 / 전체 프로젝트 작업현황과 마일스톤 정합성 확인 / plan 전 마일스톤 리프레시
- 마일스톤 완료 검토 / 종료 검토 / 현재 마일스톤 닫기 / 다음 마일스톤 지정
- 계획 작성 / plan 생성
- 코드 리뷰 / review 진행

View file

@ -8,7 +8,7 @@
- 코드 변경 전 관련 domain rule을 먼저 확인한다.
- 요청 범위를 넘는 변경을 하지 않는다.
- 불확실하면 단정하지 말고 후보를 제시한다.
- `agent-task/archive/**`는 일반 작업에서 읽지 않는다. 예외: 사용자가 과거 작업 확인, 복원, 비교, 특정 archive 경로 확인을 요청한 경우, active `PLAN-*.md` / `CODE_REVIEW-*.md` / `USER_REVIEW.md`가 특정 archive evidence 경로를 명시한 경우, plan/code-review 루프의 split subtask 선행 의존성 충족 여부를 확인하는 경우, plan 스킬이 같은 task group의 새 index를 충돌 없이 할당하는 경우, 또는 `sync-agent-ui mode=reconcile-completion`에 exact `completion-log` 경로가 전달된 경우에만 필요한 범위를 좁게 읽는다. split 의존성 확인 같은 task group의 후보 `complete.log`만 읽을 수 있다. index 할당은 같은 task group의 archived sibling directory basename만 열람하고 내부 파일은 읽지 않는다. UI 완료 정합화는 전달된 exact `complete.log` 한 건만 읽고 sibling archive log를 탐색하지 않는다.
- `agent-task/archive/**`는 일반 작업에서 읽지 않는다. 예외: 사용자가 과거 작업 확인, 복원, 비교, 특정 archive 경로 확인을 요청한 경우, active `PLAN-*.md` / `CODE_REVIEW-*.md` / `USER_REVIEW.md`가 특정 archive evidence 경로를 명시한 경우, plan/code-review 루프의 split subtask 선행 의존성 충족 여부를 확인하는 경우, plan 스킬이 같은 task group의 새 index를 충돌 없이 할당하는 경우, `sync-milestone-workstate`가 활성 대상 Milestone의 동일 `m-<milestone-slug>` task group evidence를 집계하는 경우, 또는 `sync-agent-ui mode=reconcile-completion`에 exact `completion-log` 경로가 전달된 경우에만 필요한 범위를 좁게 읽는다. split 의존성 확인과 Milestone workstate 동기화는 같은 task group의 후보 `complete.log`와 직접 연결된 plan/review log만 읽을 수 있다. index 할당은 같은 task group의 archived sibling directory basename만 열람하고 내부 파일은 읽지 않는다. UI 완료 정합화는 전달된 exact `complete.log` 한 건만 읽고 sibling archive log를 탐색하지 않는다.
- `agent-roadmap/` 디렉터리가 있는 프로젝트에서도 `agent-roadmap/archive/**`는 일반 작업에서 읽지 않는다. 로드맵 과거 완료 내용, 완료 근거, 복원, 비교가 필요한 경우에만 `agent-ops/rules/common/rules-roadmap.md`의 archive 접근 규칙을 따른다.
- `agent-ui/` 디렉터리가 있는 프로젝트에서도 `agent-ui/definition/archive/**``agent-ui/archive/user-review/**`는 일반 작업에서 읽지 않는다. UI 과거 결정, 복원, 비교, 해결된 user review 확인이 필요한 경우에만 `agent-ops/rules/common/rules-agent-ui.md`의 archive 접근 규칙을 따른다.
- `agent-spec/` 디렉터리가 있는 프로젝트에서 현재 구현 스펙 확인, 기존 기능 변경, 완료 검토, 구현 스펙 생성/갱신 요청은 세션 1회 `agent-ops/rules/common/rules-agent-spec.md`를 읽고, `agent-spec/index.md`와 매칭되는 spec 문서만 읽는다.
@ -34,7 +34,7 @@
**아래 경우에 부합되는지 반드시 끝까지 정독해서 읽고, 부합할 경우 `agent-ops/skills/common/router.md`를 작업 최초 1회 읽고 수행한다.** 자동으로 수행하지 않는다. **절대 스킵하지 말고 정독해야한다**
- agent-ops 초기화
- domain rule 생성
- skill 생성
- skill 생성/갱신
- agent-ui 생성/갱신/검증/코드 동기화, UI 스캐폴드, 화면 정의서, view/component/frame/wireframe 정의, agent-ui USER_REVIEW
- 테스트 룰 작성/생성/수정, 도메인별/검증 시나리오별 테스트 문서, create-test/update-test
- 계약 생성/업데이트, agent-contract 생성/갱신, inner/outer 계약 문서 작성/정리, 계약 포인터 관리
@ -44,6 +44,7 @@
- 로드맵/마일스톤 생성·갱신
- SDD 작성/갱신, SDD 필요 여부, SDD gate 확인, SDD 사용자 리뷰, SDD 잠금 해제
- 로드맵 현지점 / 현재 작업 지점 확인
- 현재 또는 지정 마일스톤 시작 전 정합성 체크 / 전체 프로젝트 작업현황과 마일스톤 정합성 확인 / plan 전 마일스톤 리프레시
- 마일스톤 완료 검토 / 종료 검토 / 현재 마일스톤 닫기 / 다음 마일스톤 지정
- 계획 작성 / plan 생성
- 코드 리뷰 / review 진행

View file

@ -1 +1 @@
1.1.178
1.1.188

View file

@ -26,6 +26,41 @@ create_project_agent_ops_dirs() {
mkdir -p "$agent_ops_dir/skills/private"
}
remove_generated_caches() {
local root="$1"
[ -d "$root" ] || return 0
find "$root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete
find "$root" -depth -type d \( \
-name '__pycache__' -o \
-name '.pytest_cache' -o \
-name '.mypy_cache' -o \
-name '.ruff_cache' \
\) -exec rm -rf -- {} +
}
copy_tree_without_caches() {
local src="$1"
local dst="$2"
mkdir -p "$dst"
(
cd "$src"
tar \
--exclude='__pycache__' \
--exclude='*/__pycache__' \
--exclude='.pytest_cache' \
--exclude='*/.pytest_cache' \
--exclude='.mypy_cache' \
--exclude='*/.mypy_cache' \
--exclude='.ruff_cache' \
--exclude='*/.ruff_cache' \
--exclude='*.pyc' \
--exclude='*.pyo' \
-cf - .
) | tar -C "$dst" -xf -
}
copy_common_agent_ops() {
local source_dir="$1"
local target_agent_ops_dir="$2"
@ -38,8 +73,10 @@ copy_common_agent_ops() {
rm -rf "$target_agent_ops_dir/rules/common"
rm -rf "$target_agent_ops_dir/skills/common"
cp -r "$source_dir/bin" "$target_agent_ops_dir/"
cp -r "$source_dir/rules/common" "$target_agent_ops_dir/rules/"
cp -r "$source_dir/skills/common" "$target_agent_ops_dir/skills/"
copy_tree_without_caches "$source_dir/rules/common" "$target_agent_ops_dir/rules/common"
copy_tree_without_caches "$source_dir/skills/common" "$target_agent_ops_dir/skills/common"
remove_generated_caches "$target_agent_ops_dir/rules/common"
remove_generated_caches "$target_agent_ops_dir/skills/common"
}
ensure_common_rules_file() {

View file

@ -42,6 +42,38 @@ bump_version() {
bash "$SCRIPT_DIR/bump-version.sh" "$1"
}
remove_generated_caches() {
local root="$1"
[[ -d "$root" ]] || return 0
find "$root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete
find "$root" -depth -type d \( \
-name '__pycache__' -o \
-name '.pytest_cache' -o \
-name '.mypy_cache' -o \
-name '.ruff_cache' \
\) -exec rm -rf -- {} +
}
copy_tree_without_caches() {
local src="$1" dst="$2"
mkdir -p "$dst"
(
cd "$src"
tar \
--exclude='__pycache__' \
--exclude='*/__pycache__' \
--exclude='.pytest_cache' \
--exclude='*/.pytest_cache' \
--exclude='.mypy_cache' \
--exclude='*/.mypy_cache' \
--exclude='.ruff_cache' \
--exclude='*/.ruff_cache' \
--exclude='*.pyc' \
--exclude='*.pyo' \
-cf - .
) | tar -C "$dst" -xf -
}
# ── 폴더 동기화 (삭제된 파일도 반영) ────────────────────────────────────────
sync_folder() {
local src="$1" dst="$2" exclude="${3:-}"
@ -61,10 +93,14 @@ sync_folder() {
name="$(basename "$item")"
[[ -n "$exclude" && "$name" == "$exclude" ]] && continue
rm -rf "$dst/$name"
cp -r "$item" "$dst/"
# 검증 실행 중 생긴 Python bytecode는 공통 산출물이 아니므로 전파하지 않는다.
find "$dst/$name" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete
find "$dst/$name" -depth -type d -name '__pycache__' -empty -delete
if [[ -d "$item" ]]; then
mkdir -p "$dst/$name"
copy_tree_without_caches "$item" "$dst/$name"
else
cp "$item" "$dst/"
fi
# 검증 중 생긴 cache는 공통 산출물이 아니므로 전파하지 않는다.
remove_generated_caches "$dst/$name"
done
}
@ -85,7 +121,14 @@ common_differs() {
if [[ ! -d "$src/$path" || ! -d "$dst/$path" ]]; then
return 0
fi
if ! diff -qr "$src/$path" "$dst/$path" >/dev/null; then
if ! diff -qr \
--exclude='__pycache__' \
--exclude='.pytest_cache' \
--exclude='.mypy_cache' \
--exclude='.ruff_cache' \
--exclude='*.pyc' \
--exclude='*.pyo' \
"$src/$path" "$dst/$path" >/dev/null; then
return 0
fi
done
@ -121,8 +164,10 @@ copy_common_scaffold() {
cp "$src/.version" "$dst/"
rm -rf "$dst/bin" "$dst/rules/common" "$dst/skills/common"
cp -r "$src/bin" "$dst/"
cp -r "$src/rules/common" "$dst/rules/"
cp -r "$src/skills/common" "$dst/skills/"
copy_tree_without_caches "$src/rules/common" "$dst/rules/common"
copy_tree_without_caches "$src/skills/common" "$dst/skills/common"
remove_generated_caches "$dst/rules/common"
remove_generated_caches "$dst/skills/common"
create_project_agent_ops_dirs "$dst"
}

View file

@ -21,7 +21,7 @@
- 긴 배경 설명은 README나 별도 참조 문서로 보내고, 실행 문서에는 실행 규칙만 남긴다.
- 룰 문서는 협업자가 직접 읽는 계약 문서이므로 한국어 `한다`체로 작성한다.
- README, GUIDE, roadmap 문서는 사람이 함께 검토하는 협업 문서이므로 한국어 설명체 또는 존댓말을 사용할 수 있다.
- 스킬 문서는 실행 안정성을 우선한다. 한국어 또는 영어를 사용할 수 있고, 이미 잘 동작하는 절차 계약은 언어 통일만을 위해 수정하지 않는다.
- 스킬 문서의 frontmatter `description`과 Markdown 본문은 영어로 작성한다. 기존 스킬은 언어 통일만을 위해 수정하지 않되, 새로 만들거나 실질적으로 갱신하는 스킬은 영어를 사용한다. 프로젝트가 요구하는 사용자-facing 출력 literal은 해당 언어를 유지할 수 있다.
- path, filename, 상태값, id, regex, command, frontmatter key, runtime protocol token은 원문 ASCII 식별자를 유지한다.
## 라우팅

View file

@ -52,9 +52,16 @@
## Phase와 Milestone 선택
- `current.md`는 현재 작업 위치가 아니라 활성 Phase와 활성 Milestone 후보 목록이다.
- `priority-queue.md`는 현재 작업 위치가 아니라 Phase를 가로지르는 실행 순서 문서다. 위에 있는 항목을 먼저 검토한다.
- `priority-queue.md`는 순서 전용 문서이며, Milestone 제목 링크와 식별용 한 줄 설명만 둔다. 상태, 목표, 범위, 잠금, 기능, 완료 근거, 의존성은 Milestone 문서를 원본으로 삼는다.
- `priority-queue.md`의 순서는 사용자가 순서 조정을 요청한 경우에만 바꾼다. 단, archive/폐기 제거, 경로 변경, split/merge, 실행 의미 변경, 깨진 링크 복구는 예외다.
- `priority-queue.md`는 현재 작업 위치가 아니라 Phase를 가로지르는 실행 lane과 차단 예외를 한눈에 보는 문서다.
- 각 실행 후보는 `[[<prefix>-<NN>] <Milestone 제목>](<active-milestone-path>)` 형식의 제목 링크와 1~2문장 설명을 가진다. 실행 태그는 소문자 영문으로 시작하는 ASCII prefix, 하이픈, 두 자리 숫자로 작성한다. 예: `[route-01]`.
- 같은 prefix 항목은 하나의 `### <prefix>` 그룹에 모으고 `NN` 오름차순으로 둔다. 같은 prefix에서는 더 작은 활성 `NN`이 정상 선행 순서이며, 별도 차단 줄로 반복하지 않는다.
- 서로 다른 prefix는 기본적으로 병렬 실행할 수 있다. 예외만 설명 바로 아래의 exact label `선행 차단:` 또는 `동시 차단:`과 backtick 실행 태그 목록으로 기록한다.
- `선행 차단`은 다른 prefix의 특정 Milestone 완료 전 시작할 수 없을 때만 쓴다. `동시 차단`은 두 작업을 동시에 실행할 수 없을 때 낮은 우선순위 쪽 한 곳에만 쓴다. 일반적인 관련성이나 권장 순서는 차단으로 만들지 않는다.
- 실행 태그와 차단 줄은 `priority-queue.md`가 유일한 실행 순서 원본이다. Milestone 파일 경로/slug는 안정적인 identity이며, 실행 태그를 바꿔도 파일명을 바꾸지 않는다.
- 실행 태그를 재배치하면 같은 변경에서 queue 제목, Milestone H1, 활성 `PHASE.md`와 로컬 `current.md`의 표시 제목, queue 안의 차단 참조를 함께 갱신한다. archive 문서는 재표기하지 않는다.
- runtime은 `## 실행 순서` 아래의 `### <prefix>` 그룹, numbered item의 첫 실행 태그와 링크 target, exact blocker label만 파싱한다. 설명 본문은 표시용이며 scheduling 판단에 사용하지 않는다. 중복 태그, prefix heading 불일치, malformed blocker, 존재하지 않는 active tag 참조는 fail-closed 검증 오류다.
- 실행 가능한 후보는 각 prefix에서 가장 작은 활성 `NN` 항목 중 `선행 차단`이 없는 항목이다. `동시 차단`은 참조 대상이 실제 진행 중일 때만 막는다. 여러 prefix 후보가 동시에 열리면 그룹의 문서상 위아래 순서는 기본 선택용 tie-breaker일 뿐 선행 의존성이 아니다.
- `priority-queue.md`의 실행 태그, 그룹 순서, 차단 예외는 사용자가 순서 조정을 요청한 경우에만 바꾼다. 단, archive/폐기 제거, 경로 변경, split/merge, 실행 의미 변경, 깨진 링크·태그·차단 참조 복구는 예외다.
- `priority-queue.md` 링크가 깨졌으면 추측하지 말고 활성 Milestone 문서를 기준으로 큐를 재정렬하거나 재생성한다.
- `current.md`는 공유 진행 상태가 아니며, 공유해야 할 상태는 `ROADMAP.md`, `PHASE.md`, Milestone 문서, `.agent-roadmap-sync/locks.yaml`에 남긴다.
- 활성 Phase는 `agent-roadmap/phase/**/PHASE.md`만 대상으로 한다.
@ -159,6 +166,14 @@
- 다른 Milestone에서는 같은 id를 다시 사용할 수 있다. 여러 Milestone 후보에서 같은 id가 발견되면 Milestone 이름이나 문서 경로로 대상을 확정한다.
- 사용자가 epic-id 또는 item-id를 언급하면 해당 Milestone의 Epic/Task 항목을 우선 anchor로 삼고, 기존 id는 명시적 요청 없이 바꾸지 않는다.
## Milestone 시작 전 정합성
- 사용자가 현재 또는 지정 Milestone의 시작, 정합성 체크, Plan 전 리프레시를 요청하면 `sync-milestone-workstate mode=consistency-check`로 Plan 전에 read-only preflight를 수행한다.
- preflight는 전체 활성 Phase/Milestone, `current.md`, `priority-queue.md`, workspace lock과 active agent-task를 얕게 확인하고 대상 Milestone의 목표·범위·기능·SDD·spec·contract를 현재 코드·config·proto·테스트와 깊게 대조한다. unrelated archive와 전체 코드를 무차별 탐색하지 않는다.
- 아직 구현되지 않은 미래 capability는 정상 planned delta다. 현재 owner/API/wire/config/공통 기반과 충돌하거나 이미 대체·구현된 경우만 drift로 판정한다.
- 결과는 `ready`, `refresh-required`, `blocked` 중 하나다. `refresh-required`이면 미반영 완료 Task에 대한 `sync-milestone-workstate mode=sync`와 필요한 `update-roadmap`/`roadmap-sdd`/`update-spec`/`update-contract` 갱신 후 `consistency-check`를 다시 실행하고, `blocked`이면 차단을 해소하기 전 Plan을 만들지 않는다.
- `consistency-check`는 어떤 파일도 수정하지 않는다. 완료 evidence 반영 후보만 보는 `check-only`와 의미를 합치지 않는다.
## Milestone 기반 agent-task
- `plan` 스킬이 활성 Milestone 범위의 구현 계획을 만들면 task group은 `agent-task/m-<milestone-slug>/` 형식을 사용한다.
@ -171,7 +186,7 @@
- SDD 대상 Milestone은 런타임 완료 이벤트의 `complete.log`에 있는 `Roadmap Completion`과 최종 검증 evidence가 SDD `Evidence Map`을 충족해야 roadmap Task 체크 후보가 된다. 단, 사용자가 명시적으로 evidence를 전달한 수동 `update-roadmap` 갱신에서는 Evidence Map 충족 근거를 보조 근거로 사용할 수 있다.
- 런타임 완료 이벤트가 최종 archive 경로만 갖고 있으면 `agent-task/archive/YYYY/MM/m-<milestone-slug>/...``agent-task/m-<milestone-slug>/...` 형태의 `origin-task`로 정규화해 전달한다.
- 런타임 호출에서 매칭되는 활성 Milestone이 없거나 둘 이상이면 추정하지 말고 수동 target 선택이 필요하다고 보고한다.
- `WARN` 또는 `FAIL`은 Milestone 완료 업데이트를 하지 않는다. 일반적으로 같은 `m-<milestone-slug>` task group에서 후속 계획/리뷰를 이어가지만, code-review의 user-review gate가 트리거되면 연결된 Milestone 잠금 결정을 `USER_REVIEW.md`에 남긴다.
- `WARN` 또는 `FAIL`은 Milestone 완료 업데이트를 하지 않는다. 일반적으로 같은 `m-<milestone-slug>` task group에서 후속 계획/리뷰를 이어간다. code-review의 `milestone-lock` user-review gate가 트리거되면 연결된 Milestone 잠금 결정을 `USER_REVIEW.md` 남기고, `external-execution` gate가 트리거되면 Milestone 문서를 바꾸지 않은 채 task-local `USER_REVIEW.md`에 필요한 외부 실행 조치와 재개 조건을 남긴다.
- `[스케치]` Milestone은 Milestone 기반 `agent-task` 생성 대상이 아니다. 런타임이나 plan 스킬은 이를 구현 작업으로 라우팅하지 않고 `[계획]` 승격 필요를 보고한다.
## 완료 리뷰
@ -200,6 +215,6 @@
- Milestone 아카이브 전에는 이동 전 활성 경로 identity로 `.agent-roadmap-sync/locks.yaml`을 확인한다. 해당 identity가 `rely-on.target`이면 `[완료]` 상태에서 `enable`로 동기화하고, 해당 identity가 `locked`이면 의존 조건 충족 여부를 보고하며, 어느 쪽에도 없으면 `관련 lock 없음`으로 보고한다.
- `구현 잠금`이 남아 있는 Milestone은 `[완료]` 전환이나 완료 archive 대상으로 삼지 않는다. 명시적인 폐기 근거가 있는 `[폐기]` archive는 허용한다.
- 아카이빙할 때는 활성 `ROADMAP.md` 또는 활성 `PHASE.md`에 archive 문서 링크와 짧은 요약만 남긴다.
- 아카이빙할 때 `priority-queue.md`가 있으면 이동 전 활성 Milestone 경로 항목을 제거한다. archive 경로로 바꿔 남기지 않는다.
- 아카이빙할 때 `priority-queue.md`가 있으면 이동 전 활성 Milestone 경로 항목을 제거하고, 해당 실행 태그를 가리키는 충족된 `선행 차단` 참조도 제거한다. archive 경로로 바꿔 남기지 않는다.
- 아카이브된 Phase/Milestone은 로컬 `current.md`에 남기지 않고, 일반 Phase/Milestone 선택이나 위치 분석의 후보로 삼지 않는다.
- 아카이브 문서는 과거 기록 스냅샷으로 보고, 최신 템플릿이나 스킬 규약에 맞춰 재포맷하지 않는다.

View file

@ -8,7 +8,7 @@
- 코드 변경 전 관련 domain rule을 먼저 확인한다.
- 요청 범위를 넘는 변경을 하지 않는다.
- 불확실하면 단정하지 말고 후보를 제시한다.
- `agent-task/archive/**`는 일반 작업에서 읽지 않는다. 예외: 사용자가 과거 작업 확인, 복원, 비교, 특정 archive 경로 확인을 요청한 경우, active `PLAN-*.md` / `CODE_REVIEW-*.md` / `USER_REVIEW.md`가 특정 archive evidence 경로를 명시한 경우, plan/code-review 루프의 split subtask 선행 의존성 충족 여부를 확인하는 경우, plan 스킬이 같은 task group의 새 index를 충돌 없이 할당하는 경우, 또는 `sync-agent-ui mode=reconcile-completion`에 exact `completion-log` 경로가 전달된 경우에만 필요한 범위를 좁게 읽는다. split 의존성 확인 같은 task group의 후보 `complete.log`만 읽을 수 있다. index 할당은 같은 task group의 archived sibling directory basename만 열람하고 내부 파일은 읽지 않는다. UI 완료 정합화는 전달된 exact `complete.log` 한 건만 읽고 sibling archive log를 탐색하지 않는다.
- `agent-task/archive/**`는 일반 작업에서 읽지 않는다. 예외: 사용자가 과거 작업 확인, 복원, 비교, 특정 archive 경로 확인을 요청한 경우, active `PLAN-*.md` / `CODE_REVIEW-*.md` / `USER_REVIEW.md`가 특정 archive evidence 경로를 명시한 경우, plan/code-review 루프의 split subtask 선행 의존성 충족 여부를 확인하는 경우, plan 스킬이 같은 task group의 새 index를 충돌 없이 할당하는 경우, `sync-milestone-workstate`가 활성 대상 Milestone의 동일 `m-<milestone-slug>` task group evidence를 집계하는 경우, 또는 `sync-agent-ui mode=reconcile-completion`에 exact `completion-log` 경로가 전달된 경우에만 필요한 범위를 좁게 읽는다. split 의존성 확인과 Milestone workstate 동기화는 같은 task group의 후보 `complete.log`와 직접 연결된 plan/review log만 읽을 수 있다. index 할당은 같은 task group의 archived sibling directory basename만 열람하고 내부 파일은 읽지 않는다. UI 완료 정합화는 전달된 exact `complete.log` 한 건만 읽고 sibling archive log를 탐색하지 않는다.
- `agent-roadmap/` 디렉터리가 있는 프로젝트에서도 `agent-roadmap/archive/**`는 일반 작업에서 읽지 않는다. 로드맵 과거 완료 내용, 완료 근거, 복원, 비교가 필요한 경우에만 `agent-ops/rules/common/rules-roadmap.md`의 archive 접근 규칙을 따른다.
- `agent-ui/` 디렉터리가 있는 프로젝트에서도 `agent-ui/definition/archive/**``agent-ui/archive/user-review/**`는 일반 작업에서 읽지 않는다. UI 과거 결정, 복원, 비교, 해결된 user review 확인이 필요한 경우에만 `agent-ops/rules/common/rules-agent-ui.md`의 archive 접근 규칙을 따른다.
- `agent-spec/` 디렉터리가 있는 프로젝트에서 현재 구현 스펙 확인, 기존 기능 변경, 완료 검토, 구현 스펙 생성/갱신 요청은 세션 1회 `agent-ops/rules/common/rules-agent-spec.md`를 읽고, `agent-spec/index.md`와 매칭되는 spec 문서만 읽는다.
@ -34,7 +34,7 @@
**아래 경우에 부합되는지 반드시 끝까지 정독해서 읽고, 부합할 경우 `agent-ops/skills/common/router.md`를 작업 최초 1회 읽고 수행한다.** 자동으로 수행하지 않는다. **절대 스킵하지 말고 정독해야한다**
- agent-ops 초기화
- domain rule 생성
- skill 생성
- skill 생성/갱신
- agent-ui 생성/갱신/검증/코드 동기화, UI 스캐폴드, 화면 정의서, view/component/frame/wireframe 정의, agent-ui USER_REVIEW
- 테스트 룰 작성/생성/수정, 도메인별/검증 시나리오별 테스트 문서, create-test/update-test
- 계약 생성/업데이트, agent-contract 생성/갱신, inner/outer 계약 문서 작성/정리, 계약 포인터 관리
@ -44,6 +44,7 @@
- 로드맵/마일스톤 생성·갱신
- SDD 작성/갱신, SDD 필요 여부, SDD gate 확인, SDD 사용자 리뷰, SDD 잠금 해제
- 로드맵 현지점 / 현재 작업 지점 확인
- 현재 또는 지정 마일스톤 시작 전 정합성 체크 / 전체 프로젝트 작업현황과 마일스톤 정합성 확인 / plan 전 마일스톤 리프레시
- 마일스톤 완료 검토 / 종료 검토 / 현재 마일스톤 닫기 / 다음 마일스톤 지정
- 계획 작성 / plan 생성
- 코드 리뷰 / review 진행

View file

@ -12,9 +12,9 @@
## 활성 Milestone
- [<스케치 | 계획 | 진행중 | 검토중 | 보류>] <milestone-name>
- [<스케치 | 계획 | 진행중 | 검토중 | 보류>] [<prefix>-<NN>] <milestone-name>
- Phase: [PHASE.md](phase/<phase-slug>/PHASE.md)
- 경로: [<milestone-name>](phase/<phase-slug>/milestones/<milestone-slug>.md)
- 경로: [[<prefix>-<NN>] <milestone-name>](phase/<phase-slug>/milestones/<milestone-slug>.md)
## 선택 규칙

View file

@ -1,4 +1,4 @@
# Milestone: <Milestone 이름>
# Milestone: [<prefix>-<NN>] <Milestone 이름>
## 위치
@ -39,7 +39,7 @@
- [ ] <SDD가 필요한 경우: SDD 잠금이 해제되어 있다>
- [ ] <SDD가 필요한 경우: SDD 사용자 리뷰가 없거나 승인/해결되었다>
- [ ] <SDD가 필요한 경우: Acceptance Scenario가 Milestone 기능 Task와 연결되어 있다>
- [ ] <SDD가 필요한 경우: Evidence Map이 완료 Roadmap Completion과 최종 검증 evidence로 검증 가능하게 연결되어 있다>
- [ ] <SDD가 필요한 경우: Evidence Map이 완료 complete.log의 milestone-task id별 집계와 최종 검증 evidence로 검증 가능하게 연결되어 있다>
- 결정 필요: <없음 | 아래 목록>
- <에이전트가 확정할 없는 제품/범위/우선순위/책임 경계 결정 항목>
@ -87,6 +87,6 @@ Task 체크리스트는 Epic 바로 아래의 flat list로 유지하고, 구현
- 관련 경로: `<path>`
- 표준선(선택): <기존 구조, 도메인 rule, 플랫폼 관례, 업계 표준으로 진행할 기본 기준>
- 선행 작업: <없음 또는 파일 위치 기준 Markdown 링크(예: [Milestone 이름](../other-milestone.md))/태스크 이름>
- 후속 작업: <없음 또는 파일 위치 기준 Markdown 링크(예: [Milestone 이름](../next-milestone.md))/태스크 이름>
- 실행 순서와 차단 관계: [전역 마일스톤 실행 순서](../../../priority-queue.md)
- 관련 Milestone(선택): <없음 또는 파일 위치 기준 Markdown 링크(예: [Milestone 이름](../other-milestone.md))>
- 확인 필요: <없음 | `구현 잠금 > 결정 필요` 또는 [USER_REVIEW.md](../../../sdd/<phase-slug>/<milestone-slug>/USER_REVIEW.md) 분리할 항목>

View file

@ -16,8 +16,8 @@ Milestone은 완료, 검토중, 진행중, 계획, 스케치 또는 보류 상
Phase를 가로지르는 실제 다음 작업 선택은 [전역 마일스톤 실행 순서](../../priority-queue.md)를 우선한다.
스케치 Milestone은 아직 구현 가능한 계획이 아니므로 계획 Milestone보다 아래에 둔다.
- [<스케치 | 계획 | 진행중 | 검토중 | 완료 | 보류 | 폐기>] <Milestone 이름>
- 경로: [<Milestone 이름>](milestones/<milestone-slug>.md) 또는 [archive <Milestone 이름>](../../archive/phase/<phase-slug>/milestones/<milestone-slug>.md)
- [<스케치 | 계획 | 진행중 | 검토중 | 완료 | 보류 | 폐기>] [<prefix>-<NN>] <Milestone 이름>
- 경로: [[<prefix>-<NN>] <Milestone 이름>](milestones/<milestone-slug>.md) 또는 [archive <Milestone 이름>](../../archive/phase/<phase-slug>/milestones/<milestone-slug>.md)
- 요약: <목표 또는 결과 1문장>
## Phase 경계

View file

@ -27,8 +27,10 @@
## 전역 마일스톤 실행 순서
- {없음 | [MILESTONE_TITLE](agent-roadmap/phase/<phase-slug>/milestones/<milestone-slug>.md): 식별용 한 줄 설명}
- {[MILESTONE_TITLE](agent-roadmap/phase/<phase-slug>/milestones/<milestone-slug>.md): 식별용 한 줄 설명 | 큐 정리 필요: 사유}
### {prefix}
- {없음 | [[prefix-NN] MILESTONE_TITLE](agent-roadmap/phase/<phase-slug>/milestones/<milestone-slug>.md): 설명 1~2문장}
- {lane head | 선행 차단: [tag] | 동시 차단: [tag] | 큐 정리 필요: 사유}
## 전체 Phase 흐름

View file

@ -1,17 +1,28 @@
# 전역 마일스톤 실행 순서
이 문서는 Phase를 가로지르는 Milestone 실행 순서만 기록한다. 위에 있는 항목을 먼저 검토한다.
이 문서는 Phase를 가로지르는 Milestone 실행 lane과 차단 예외를 기록한다. 같은 prefix는 순차 실행하고, 다른 prefix는 차단 표기가 없으면 병렬 실행할 수 있다.
## 실행 순서
<!--
작성 규칙:
- 실행 후보가 없으면 이 섹션을 비워 둔다.
- 항목은 Milestone 제목 링크와 식별용 한 줄 설명만 둔다.
- 같은 prefix 항목을 `### <prefix>` 아래에 모으고 두 자리 index 오름차순으로 둔다.
- 항목 제목은 `[[<prefix>-<NN>] <Milestone 제목>](<path>)` 형식으로 쓰고 설명 1~2문장을 반드시 둔다.
- 같은 prefix의 작은 index가 기본 선행 순서다. 이 관계를 `선행 차단`으로 반복하지 않는다.
- 다른 prefix의 완료가 반드시 필요할 때만 설명 아래에 exact label `선행 차단:`과 backtick tag 목록을 둔다.
- 동시 실행이 실제로 충돌할 때만 낮은 우선순위 항목에 exact label `동시 차단:`과 backtick tag 목록을 둔다.
- 상태, 목표, 범위, 잠금, 기능, 완료 근거는 각 Milestone 문서를 원본으로 삼는다.
- 링크 target은 이 파일 위치 기준 상대 경로로 쓴다.
예:
1. [Milestone 제목](phase/<phase-slug>/milestones/<milestone-slug>.md)
이 Milestone에서 다루는 작업을 한 줄로 적는다.
### route
1. [[route-01] Hot Path](phase/<phase-slug>/milestones/<milestone-slug>.md)
이 Milestone에서 다루는 작업과 결과를 1~2문장으로 적는다.
2. [[route-02] Hybrid Routing](phase/<phase-slug>/milestones/<milestone-slug>.md)
Hot Path 결과를 일반 요청 라우팅으로 확장한다.
- 선행 차단: `[observe-02]`
- 동시 차단: `[output-01]`
-->

View file

@ -57,7 +57,7 @@
| Scenario | Required Evidence | `agent-task` 연결 | 완료 Evidence 기대 |
|----------|-------------------|------------------|---------------------------|
| S01 | <test/smoke/search/user-review evidence> | `agent-task/m-<milestone-slug>/...` | <complete.log의 Roadmap Completion/최종 검증으로 남길 근거> |
| S01 | <test/smoke/search/user-review evidence> | `agent-task/m-<milestone-slug>/...` | <complete.log의 milestone-task id별 집계/최종 검증으로 확인할 근거> |
## Cross-repo Dependencies

View file

@ -4,11 +4,12 @@
- 전역 Milestone 실행 순서는 [전역 마일스톤 실행 순서](priority-queue.md)를 먼저 확인한다.
- Phase는 도메인/책임 영역이며 순차 실행 게이트가 아니다.
- Phase 흐름과 상태는 로드맵 구조를 설명하고, 실제 다음 작업 선택은 `priority-queue.md`위아래 순서를 우선한다.
- Phase 흐름과 상태는 로드맵 구조를 설명하고, 실제 다음 작업 선택은 `priority-queue.md`prefix별 index와 차단 표기를 따른다.
- `priority-queue.md`는 순서 전용 문서이며, 상태, 목표, 범위, 잠금, 기능, 완료 근거는 각 Milestone 문서를 원본으로 삼는다.
- `priority-queue.md` 항목은 Milestone 제목 링크와 식별용 한 줄 설명만 둔다.
- `priority-queue.md` 항목은 `[prefix-NN]`을 포함한 Milestone 제목 링크, 1~2문장 설명, 필요한 `선행 차단`/`동시 차단` 예외만 둔다.
- `priority-queue.md`는 로드맵 생성 시 함께 만들며, 실행 후보가 없을 때도 문서와 `실행 순서` 섹션은 유지한다.
- `priority-queue.md`는 사용자가 순서 조정을 요청한 경우, Milestone archive 시 완료 항목 제거가 필요한 경우, 큐에 있는 Milestone이 폐기, 경로 변경, split/merge, 또는 실행 의미가 바뀔 정도로 수정된 경우에만 재정렬한다.
- 같은 prefix의 작은 index는 큰 index보다 먼저 실행하고, 다른 prefix는 차단 표기가 없으면 병렬 실행할 수 있다. 여러 열린 prefix 중 문서상 위쪽 그룹은 기본 선택 tie-breaker다.
- `priority-queue.md`는 사용자가 순서 조정을 요청한 경우, Milestone archive 시 완료 항목과 충족된 차단 참조 제거가 필요한 경우, 큐에 있는 Milestone이 폐기, 경로 변경, split/merge, 또는 실행 의미가 바뀔 정도로 수정된 경우에만 재정렬한다.
- `priority-queue.md`의 링크가 깨졌으면 활성 Milestone 문서를 기준으로 큐를 재정렬하거나 재생성한다.
## 전체 목표

View file

@ -1,56 +1,51 @@
---
name: <skill-name>
version: 1.0.0
description: <skill이 하는 일을 줄로 설명. 트리거 키워드 포함 권장>
description: <Describe in one sentence what this skill does and when to use it. Include trigger keywords when useful.>
---
# <skill-name>
## 목적
## Purpose
<skill이 해결하는 문제를 1~2문장으로 설명>
<Explain the problem this skill solves in one or two sentences.>
## 언제 호출할지
## When to use
- < skill을 호출해야 하는 상황 1>
- < skill을 호출해야 하는 상황 2>
- < skill을 호출해야 하는 상황 3>
- <Invocation case 1>
- <Invocation case 2>
## 입력
<!-- Optional: remove this comment and the entire Inputs section when the skill has no explicit parameters. -->
## Inputs
- `<param-name>`: <설명> (필수)
- `<param-name>`: <설명> (선택)
- `<param-name>`: <Description.> (required)
## 먼저 확인할 것
<!-- Optional: remove this comment and the entire Preflight section when no check must precede execution. -->
## Preflight
- [ ] <실행 반드시 확인해야 조건 1>
- [ ] <실행 반드시 확인해야 조건 2>
- [ ] <Condition that must be checked before execution.>
## 실행 절차
## Procedure
1. **<단계명>**
- <세부 행동>
- <세부 행동>
1. **<Step name>**
- <Required action.>
2. **<단계명>**
- <세부 행동>
2. **Report the result**
- <Result to report.>
3. **결과 보고**
- <출력할 내용>
## Validation
## 실행 결과 검증
- [ ] <Verifiable success condition.>
- If validation fails, <define the retry, rollback, or reporting action.>
- [ ] <실행 확인해야 성공 조건 1>
- [ ] <실행 확인해야 성공 조건 2>
- 검증 실패 시: <실패 취할 행동 롤백, 사용자 알림, 재시도 >
## 출력 형식
<!-- Optional: remove this comment and the entire Output format section unless an exact shape is required. -->
## Output format
```
<출력 예시>
<Output example.>
```
## 금지 사항
<!-- Optional: remove this comment and the entire Prohibitions section unless a specific likely or unsafe action must be forbidden. -->
## Prohibitions
- <절대 하면 되는 >
- <절대 하면 되는 >
- <Forbidden action.>

View file

@ -1,7 +1,8 @@
---
name: analyze-roadmap-position
version: 1.13.0
description: "여러 레포를 전환할 때 코드/git 분석 없이 priority-queue 실행 순서와 ROADMAP > Phase > Milestone > current 현지점을 링크 달린 흐름 목록으로 빠르게 보여주는 읽기 전용 스킬"
metadata:
version: "1.14.0"
description: "여러 레포를 전환할 때 코드/git 분석 없이 priority-queue 실행 순서와 ROADMAP → Phase → Milestone → current 현지점을 링크 달린 흐름 목록으로 빠르게 보여주는 읽기 전용 스킬"
---
# 로드맵 현지점
@ -10,7 +11,7 @@ description: "여러 레포를 전환할 때 코드/git 분석 없이 priority-q
여러 레포를 병렬 운용하다가 돌아왔을 때, 현재 작업이 전체 로드맵의 어느 Phase와 Milestone에 있는지 빠르게 보여준다.
기본 출력은 전역 Milestone 실행 순서, `로드맵 > Phase > Milestone` breadcrumb, 전체 Phase 흐름, 현재 Phase의 Milestone 흐름이다.
Phase 흐름은 실행 순서가 아니라 도메인/책임 영역의 구조적 지도이며, 다음 작업 후보는 `priority-queue.md` 순서를 우선한다.
Phase 흐름은 실행 순서가 아니라 도메인/책임 영역의 구조적 지도이며, 다음 작업 후보는 `priority-queue.md`의 prefix별 index와 차단 예외를 우선한다.
코드 진행도 감사, git diff 분석, 테스트 근거 확인, 남은 작업 정밀 판정은 기본 책임이 아니다.
## 언제 호출할지
@ -39,8 +40,9 @@ Phase 흐름은 실행 순서가 아니라 도메인/책임 영역의 구조적
2. `agent-ops/skills/common/_templates/roadmap-position-report-template.md`를 읽는다.
3. 로컬 `agent-roadmap/current.md`를 확인한다.
- 없으면 로컬 current 없음으로 보고하고, `priority-queue.md`가 있으면 전역 실행 순서와 `ROADMAP.md``Phase 흐름`을 보여준다.
4. `agent-roadmap/priority-queue.md`가 있으면 `실행 순서` 항목을 읽어 전역 Milestone 실행 순서 목록을 만든다.
- 각 항목은 순번, Milestone 제목 링크, 식별용 한 줄 설명만 남긴다.
4. `agent-roadmap/priority-queue.md`가 있으면 `실행 순서` 항목을 읽어 prefix별 Milestone 실행 lane 목록을 만든다.
- 각 항목은 `[prefix-NN]`, Milestone 제목 링크, 1~2문장 설명, `선행 차단``동시 차단`을 그대로 남긴다.
- 같은 prefix의 작은 active index를 lane head로 표시하고, 다른 prefix의 head는 차단이 없으면 병렬 가능 후보로 표시한다.
- 상태, 잠금, 목표, 기능 Task는 각 Milestone 문서 원본을 읽기 전에는 추정하지 않는다.
- archive 링크가 있거나 링크가 깨진 것으로 보이면 `큐 정리 필요`로 표시하고 archive 문서는 읽지 않는다.
- 파일이 없으면 `전역 실행 순서: 없음`으로 출력한다.
@ -100,7 +102,7 @@ Phase 흐름은 실행 순서가 아니라 도메인/책임 영역의 구조적
- 현재 후보의 역할 태그는 `선행 스케치`, `다음 구현 계획`, `검토 후보`, `보류 후보`처럼 짧게 쓰되 SDD 상태를 역할 태그에 넣지 않는다.
- 현재 후보에 `SDD 문서` 링크/경로가 있으면 Milestone 아래에 SDD 링크를 배치한다. `USER_REVIEW.md`가 있으면 그 링크도 SDD 아래에 배치한다.
- 문서 포인터는 항상 `[표시 제목](상대경로)` Markdown 링크로 출력한다. 파일이 없어도 raw path만 쓰지 말고 `SDD_LINK (파일 없음)`처럼 링크와 상태를 함께 쓴다.
- `전역 마일스톤 실행 순서`에는 `priority-queue.md`항목 순서를 그대로 출력한다. Milestone 상태나 잠금은 각 Milestone 문서를 읽지 않았다면 출력하지 않는다.
- `전역 마일스톤 실행 순서`에는 `priority-queue.md`prefix 그룹, index, 설명, blocker를 그대로 출력한다. Milestone 상태나 구현 잠금은 각 Milestone 문서를 읽지 않았다면 출력하지 않는다.
- 로드맵이 없는 프로젝트에서는 로드맵 없음으로 짧게 보고하고 템플릿을 억지로 채우지 않는다.
- 로컬 `current.md`가 없으면 `local current: 없음`으로 출력하고, `[current.md](agent-roadmap/current.md)` 링크를 만들지 않는다.

View file

@ -15,38 +15,45 @@ plan skill -> finalize-task-routing -> implementation -> code-review skill
+----- WARN/FAIL: invoke plan skill with raw findings -+
```
Implementation agents never decide or request user review. They record implementation, verification, deviation, and blocker evidence in implementation-owned review fields. The official code-review agent alone evaluates the selected Milestone state and, when the review-agent-owned gate is justified, writes `USER_REVIEW.md` from `agent-ops/skills/common/code-review/templates/user-review-template.md`.
Implementation agents never decide or request user review. They record implementation, verification, deviation, and blocker evidence in implementation-owned review fields. The official code-review agent alone evaluates the review-agent-owned gate and, when justified, writes `USER_REVIEW.md` from `agent-ops/skills/common/code-review/templates/user-review-template.md`.
## Core Loop Rules
- Trigger: Korean or English active-task review requests, including `리뷰 진행해` and `리뷰해줘`, must use this skill when an active `CODE_REVIEW-*-G??.md` or `USER_REVIEW.md` exists under `agent-task/*/` or `agent-task/*/*/`, excluding `agent-task/archive/**`.
- Finalize every selected active state: for `CODE_REVIEW-*-G??.md`, append one verdict, prepare the required next state, archive the active review and plan files, then materialize exactly one next state; for `USER_REVIEW.md` completion, update the stop state, write `complete.log`, and archive the task.
- Finalize every selected state unless Step 5 returns blocked. Normally append one verdict, prepare one next state, archive the pair, and materialize that state. For `dependency-wait`, keep the pair and persist `next-task`/resume condition without another verdict. A resolved `USER_REVIEW.md` writes `complete.log` and archives the task.
- Next state: `PASS` writes `complete.log` and moves the task under `agent-task/archive/YYYY/MM/`; if the task group is `m-<milestone-slug>`, report completion metadata for the runtime event. `WARN` or `FAIL` normally invokes `agent-ops/skills/common/plan/SKILL.md`, which must run `finalize-task-routing` before writing the next active pair; if the user-review gate triggers, write `USER_REVIEW.md` instead. A completed `USER_REVIEW.md` uses the same terminal `complete.log` and archive path as `PASS`.
- The user-review gate is review-agent-owned and triggers only when current repository evidence proves that a concrete selected Milestone `구현 잠금 > 결정 필요` item blocks the next safe implementation step. Generic status fields or blocker text written by implementation are never a user-review request.
- The user-review gate is review-agent-owned and triggers only when current evidence proves either that a concrete selected Milestone `구현 잠금 > 결정 필요` item blocks the next safe implementation step or that required external verification cannot proceed without a user-controlled capability or authorization. Generic status fields or blocker text written by implementation are never a user-review request.
- Do not replace `USER_REVIEW.md` with an inline user question. When the user-review gate triggers, write the file-based stop state and report its path.
- Do not ask for confirmation before WARN/FAIL follow-up files. If the user-review gate triggers, write `USER_REVIEW.md`; otherwise invoke the plan skill with the current raw findings and let it write the smallest concrete follow-up after fresh routing.
- Recovery: if a prior turn appended a verdict without archive or next-state files, do not append another verdict; resume Step 5 preparation/archive from that verdict. If exactly one member of the pair was archived after both archive destinations had been preflighted, verify the archived member and remaining source/destination, finish that archive, then use the post-archive recovery below. If both logs exist with a verdict but the required next state is absent, reconstruct it from those exact logs: PASS resumes `complete.log`; WARN/FAIL reruns the plan skill in `write` mode with raw archived findings and `isolated-reassessment`; a valid user-review gate rerenders `USER_REVIEW.md`. If a prior turn resolved `USER_REVIEW.md` without `complete.log`, resume at the matching finalization step.
- Recovery: if a prior turn appended a verdict without archive or next-state files, do not append another verdict; resume Step 5 preparation/archive from that verdict. If a pre-existing verdict predates stable finding ids, assign `R1..` and `S1..` once in displayed order in the recovery handoff and record that mapping without appending a second verdict. If exactly one member of the pair was archived after both archive destinations had been preflighted, verify the archived member and remaining source/destination, finish that archive, then use the post-archive recovery below. If both logs exist with a verdict but the required next state is absent, reconstruct it from those exact logs: PASS resumes `complete.log`; WARN/FAIL reruns the plan skill in `write` mode with raw archived findings and `isolated-reassessment`; a valid user-review gate rerenders `USER_REVIEW.md`. If a prior turn resolved `USER_REVIEW.md` without `complete.log`, resume at the matching finalization step.
## User Review Gate
`USER_REVIEW.md` is a loop stop state only for selected Milestone lock decisions. Default to a normal WARN/FAIL follow-up; the gate requires positive evidence that the blocker is already represented, or must be represented, as a Milestone `구현 잠금 > 결정 필요` item.
`USER_REVIEW.md` is a loop stop state with exactly one of these types:
- `milestone-lock`: a concrete selected Milestone `구현 잠금 > 결정 필요` item requires a user decision.
- `external-execution`: required verification needs an exact user-controlled runner, device, credential, interactive session, evidence handoff, or explicit authorization that no currently authorized executor can use.
Apply these rules:
- Compute `review-number` as `count(agent-task/{task_name}/code_review_*.log) + 1` before archiving the active review.
- Repeated `WARN`/`FAIL`, loop exhaustion, missing verification evidence, and test environment blockers do not trigger `USER_REVIEW.md` by themselves. Invoke the plan skill for a narrower follow-up or report the non-roadmap blocker as verification evidence that remains unresolved.
- Resolve the selected Milestone from `Roadmap Targets` or another exact active Milestone path already fixed by the task. Read its current `구현 잠금 > 결정 필요` items and independently determine whether one exact unresolved decision blocks the next safe implementation step.
- External environment/secret/service setup, unsupported device, generic scope conflict, agent execution limits, missing handoff evidence, incomplete verification records, arbitrary `상태` text, or anything not tied to that exact Milestone lock never triggers the gate. Archive the review and invoke the plan skill for a normal WARN/FAIL follow-up when implementation can continue, or report the non-user-review blocker.
- `USER_REVIEW.md` is created from `agent-ops/skills/common/code-review/templates/user-review-template.md`, filled with the archived loop history, current archived plan/review paths, verdict, loop count, blocking evidence, connection target, and the exact Milestone decision item.
- Repeated `WARN`/`FAIL`, loop exhaustion, missing verification evidence, and a transient test failure do not trigger `USER_REVIEW.md` by themselves.
- For `milestone-lock`, resolve the selected Milestone from the first path segment of the task header (`m-<milestone-slug>`) or another exact active Milestone path already fixed by the task. Read its current `구현 잠금 > 결정 필요` items and require one exact unresolved decision that blocks the next safe implementation step.
- For `external-execution`, first resolve the repository-declared runner, transport, workdir, credentials source, and safe read-only preflight. Use an already authorized configured executor, including SSH or another declared remote runner, when it can perform the step. A current-host OS mismatch, missing local command, closed current-host localhost port, agent execution limit, or incomplete evidence is not enough while such an executor remains usable.
- Trigger `external-execution` only when the required target and attempted routing/preflight are concrete, the next verification step is required for the verdict, no authorized automatic route can perform it, and progress requires a user to grant access or authorization, prepare or operate a user-controlled environment, or supply the required evidence. Do not create another follow-up PLAN that repeats the same inaccessible preflight.
- Generic scope conflict, missing optional handoff evidence, arbitrary `상태` text, and repository-fixable setup remain normal WARN/FAIL follow-up inputs.
- Create `USER_REVIEW.md` from `agent-ops/skills/common/code-review/templates/user-review-template.md`. Fill the archived loop history, current archived plan/review paths, verdict, loop count, blocking evidence, exact target, one gate type, required user action or decision, and resume condition.
## User Review Resolution
When an active `USER_REVIEW.md` exists and the linked Milestone decision closes the task as complete/PASS, finalization is still owned by this skill.
When an active `USER_REVIEW.md` exists and its recorded user action or decision closes the task as complete/PASS, finalization is still owned by this skill.
- Read `USER_REVIEW.md`, archived `plan_*.log`, and archived `code_review_*.log` in that task directory.
- Verify the linked decision and any follow-up evidence are sufficient to close the task. If a new implementation plan is needed instead, do not close; route back to the plan skill, which archives `USER_REVIEW.md` to `user_review_N.log` before writing a new plan.
- Update `USER_REVIEW.md` in place to show a resolved state, final verdict, loop history, fulfilled decision items, and the evidence that closed the stop state.
- Verify the recorded action or decision and any follow-up evidence are sufficient to close the task. For `external-execution`, access or authorization that merely enables verification normally resumes through a new plan; user-supplied final evidence may close the task only when it satisfies the archived acceptance criteria. If a new implementation or verification plan is needed, do not close; route back to the plan skill, which archives `USER_REVIEW.md` to `user_review_N.log` before writing a new plan.
- Update `USER_REVIEW.md` in place to show a resolved state, final verdict, loop history, fulfilled user actions or decisions, and the evidence that closed the stop state.
- Write `complete.log` from `agent-ops/skills/common/code-review/templates/complete-log-template.md` before moving or archiving the task artifacts. Include both the original archived review verdict and the user-review resolution line in `루프 이력`.
- Then apply the same task-directory archive move and `m-<milestone-slug>` PASS completion metadata rules as a normal `PASS`.
- Do not leave an active task directory that contains `USER_REVIEW.md` and `*.log` files but no `complete.log` after the linked Milestone decision resolves the task as complete/PASS.
- Do not leave an active task directory that contains `USER_REVIEW.md` and `*.log` files but no `complete.log` after the recorded action or decision resolves the task as complete/PASS.
## Workflow Contract
@ -84,13 +91,15 @@ Milestone task group contract:
- `agent-task/m-<milestone-slug>/` is reserved for Milestone-linked work created by the plan skill.
- Do not treat normal task groups that do not start with `m-` as runtime milestone completion targets.
- For a selected task path, parse only the first path segment as `{task_group}`. If it matches `^m-[a-z0-9][a-z0-9-]*$`, strip `m-` to get `<milestone-slug>`.
- Do not modify `agent-roadmap/**` for milestone routing during code-review finalization. Read only the Milestone path from `Roadmap Targets` and its SDD path when needed to verify SDD Evidence Map.
- Do not call `update-roadmap` from this skill. The runtime consumes the PASS completion event, checks current state, resolves the active Milestone, and calls `update-roadmap` if needed.
- For `m-<milestone-slug>` PASS tasks, report the original active task path, final archive path, complete log path, task group, and milestone slug so the runtime has deterministic event inputs.
- Do not modify `agent-roadmap/**` for milestone routing during code-review finalization. Resolve the active Milestone from the `m-<milestone-slug>` task group and read its SDD path only when needed to verify the first-line `milestone-task` ids against the SDD Evidence Map.
- Do not call `update-roadmap` from this skill. The runtime consumes the PASS completion event and invokes `sync-milestone-workstate`, which aggregates all same-group `complete.log` evidence before changing a Task checkbox.
- For `m-<milestone-slug>` PASS tasks, report the original active task path, final archive path, complete log path, task group, milestone slug, and `milestone-task` ids so the runtime has deterministic aggregation inputs.
Follow-up routing boundary:
- This skill records current source, actual verification output, and findings, but it must not estimate or recommend the next lane/G.
- Decide each Required/Suggested disposition here and validate it directly; dispatcher use is optional. Default repository-fixable work to `direct-fix`, expanding stale exclusions when required by original acceptance. Allow `verified-dependency` only with the exact PLAN/task ordering proof, or `complete.log` plus fresh proof that the precondition is satisfied; vague owners and `complete.log` alone are invalid. Set `ownership_closed=true` only after every id has that proof.
- Never send an unchanged-precondition verification packet. For an unordered dependency, keep the verdict pair and return `status=blocked`, `blocked_reason=dependency-wait`, `next-task`, and resume condition; do not redispatch it or request user review.
- On WARN/FAIL, invoke the plan skill in `prepare-follow-up` mode with the selected task path and raw current evidence before archiving the current pair.
- Do not pass the archived lane, grade, routing score, rationale, or filename as plan-routing input. Archive paths remain evidence pointers, and actual logs/findings remain raw evidence.
- The plan skill must complete its full analysis and mandatory `finalize-task-routing` step before it writes the next pair. Code-review must not create a routed follow-up pair directly.
@ -102,10 +111,10 @@ Directory states:
|-------|---------|
| `PLAN-*-G??.md` + unfilled `CODE_REVIEW-*-G??.md` stub/placeholders | Implementation is not judgeable; review should fail completeness if invoked |
| `PLAN-*-G??.md` + filled `CODE_REVIEW-*-G??.md` without verdict | Ready for code-review skill |
| `PLAN-*-G??.md` + `CODE_REVIEW-*-G??.md` with appended verdict | Review finalization pending; do not append another verdict, resume Step 5 preparation/archive |
| `PLAN-*-G??.md` + `CODE_REVIEW-*-G??.md` with appended verdict | Review finalization pending or `dependency-wait`; do not append another verdict. Resume Step 5 immediately for unfinished finalization, or only after the recorded dependency changes for a wait. |
| Exactly one active pair member + its newly archived counterpart | Partial archive after a preflighted finalization; verify both identities, finish the remaining archive, then resume post-archive recovery |
| `complete.log` + `*.log` files | Task complete (PASS or user-review-resolved PASS), before final task-directory archive move |
| `USER_REVIEW.md` + `*.log` files | Automatic loop stopped; linked Milestone lock decision must be resolved before creating another plan |
| `USER_REVIEW.md` + `*.log` files | Automatic loop stopped; its recorded Milestone decision or external-execution user action must be resolved before creating another plan |
| `agent-task/archive/YYYY/MM/{task_name}/complete.log` + `*.log` files | Archived completed task path (PASS or user-review-resolved PASS); not active |
| Only `*.log` files (no `complete.log`) | If the newest review log has a verdict and its required next state is absent, post-archive finalization is pending; otherwise the task is terminated mid-loop or abandoned |
@ -128,8 +137,8 @@ Classify the combined set of active `CODE_REVIEW-*-G??.md` and `USER_REVIEW.md`
| Result | Action |
|--------|--------|
| Exactly one active path and it is `CODE_REVIEW-*-G??.md` | Review that task; exactly one `PLAN-*-G??.md` is normally expected beside it. If the review already has a verdict and its exact plan counterpart was just archived, use partial-archive recovery instead of reporting a missing plan. |
| Exactly one active path and it is `USER_REVIEW.md`, with a linked Milestone completion/resolution decision | Perform User Review Resolution for that task. |
| One or more active paths and every active path is `USER_REVIEW.md`, with no completion/resolution decision | Report that the linked Milestone decision is required and list the paths. |
| Exactly one active path and it is `USER_REVIEW.md`, with a recorded user action/decision resolution | Perform User Review Resolution for that task. |
| One or more active paths and every active path is `USER_REVIEW.md`, with no action/decision resolution | Report the required user action or decision and list the paths. |
| No active paths | Apply the finalization-recovery scan below; stop only when it finds no recoverable task. |
| Multiple active paths | If the user/runtime named a task group, task path, or subtask directory that identifies exactly one active path, use that directory. Otherwise list paths and stop with an ambiguity report; do not choose by agent judgment and do not create a user-review request for routing ambiguity. |
@ -164,12 +173,12 @@ Before writing the verdict:
- Compare actual source files against every planned checklist item.
- Compare the plan `Implementation Checklist` and review stub `Implementation Checklist` (legacy: `구현 체크리스트`); repair non-behavioral drift when implementation remains judgeable.
- When the active artifacts have `Roadmap Targets`, check whether the referenced Milestone has `SDD: 필요`. When it does, read only that Milestone and its SDD, compare implementation evidence against the SDD Acceptance Scenarios/Evidence Map for the targeted task ids, and fail completeness or verification trust when evidence is insufficient. Do not require a separate SDD target section.
- When the active artifacts use an `m-*` task header, require identical non-empty `milestone-task` ids in PLAN and CODE_REVIEW, resolve the active Milestone by slug, and verify every id exists. If the Milestone has `SDD: 필요`, read only that Milestone and its SDD, compare implementation evidence against the SDD Acceptance Scenarios/Evidence Map for those ids, and fail completeness or verification trust when evidence is insufficient.
- Directly repair obvious non-behavioral source nits when safe: typos, stale comments, docs, or formatting only, with no behavior/test/API contract change.
- If a checklist item contains integrated verification for a feature, treat that feature item as incomplete until both implementation evidence and the matching verification output are present. Do not accept a separate unchecked completion-criteria item as a substitute.
- Confirm the implementation marked the matching checklist items in the active review file, including the mandatory `CODE_REVIEW-*-G??.md` evidence item; repair clear artifact drift when evidence supports completion.
- Treat review artifact gaps as failures only when they prevent judging implementation correctness, tests, contracts, or verification trust.
- Treat every generic `상태` field and implementation blocker record as ordinary evidence, never as a request to stop for the user. Evaluate the user-review gate independently from the current selected Milestone only after a WARN/FAIL finding requires a next state.
- Treat every generic `상태` field and implementation blocker record as ordinary evidence, never as a request to stop for the user. Evaluate both user-review gate types independently only after a WARN/FAIL finding requires a next state.
- Grep renamed/removed symbols for stale references.
- Confirm every required test exists, name matches, and assertions are meaningful.
- Cross-check claimed verification output in the active review file against actual code and project commands.
@ -182,8 +191,8 @@ Append the review result to the active `CODE_REVIEW-*-G??.md`. For a canonical E
Required fields for canonical English active pairs:
- `Overall Verdict`: exactly `PASS`, `WARN`, or `FAIL`.
- `Dimension Assessment`: Pass/Warn/Fail for correctness, completeness, test coverage, API contract, code quality, implementation deviation, verification trust. If SDD Evidence Map applies through `Roadmap Targets`, also include spec conformance.
- `Findings`: `None`, or bullets using `Required`, `Suggested`, or `Nit` with `file:line` and a concrete fix.
- `Dimension Assessment`: Pass/Warn/Fail for correctness, completeness, test coverage, API contract, code quality, implementation deviation, verification trust. If SDD Evidence Map applies through `milestone-task`, also include spec conformance.
- `Findings`: `None`, or bullets using stable `Required R1`, `Required R2`, `Suggested S1`, or `Suggested S2` ids with `file:line` and a concrete fix; Nit findings do not need ids. Keep every Required/Suggested id unchanged in the follow-up handoff and plan.
- `Routing Signals`: calculate once and append `review_rework_count=<N>` and `evidence_integrity_failure=true|false`. Set rework count to archived same-task `WARN|FAIL` verdicts plus one only when the current verdict is non-PASS. Set integrity failure to true only when a claimed test, command, exit code, or production path is absent, unexecuted, or contradicted by fresh reviewer evidence.
- `Next Step`: keep only the matching PASS, WARN/FAIL follow-up, or USER_REVIEW line.
@ -222,12 +231,14 @@ Do not archive WARN/FAIL files until the next-state content is fully prepared in
Reuse the routing signals appended in Step 4; do not recount verdict history for routing. Separately count the existing logs once for archive identity: set `current_review_archive_number=count(code_review_*.log)` and `current_plan_archive_number=count(plan_*.log)`, then derive both archive names from the current active files' own lane/grade. These archive values describe the pair being closed, not the next route.
The follow-up handoff contains the selected `{task_name}`, the current plan's requested outcome/acceptance/exclusions revalidated against current evidence, current verdict, Required/Suggested/Nit findings, affected files, actual verification output, current ownership/dependency facts, roadmap carryover, `review_rework_count`, `evidence_integrity_failure`, `REVIEW_<PARENT_TAG>`, and those predicted current-pair archive names. Keep current active paths only as evidence pointers. Do not add the prior lane, grade, routing score, rationale, or a preferred next route to the neutral routing snapshot, and require plan to omit route-bearing basenames from the isolated routing input. The plan may use current archive names only after routing to render `Archive Evidence Snapshot`.
The follow-up handoff contains the selected `{task_name}`, revalidated outcome/acceptance/exclusions, current verdict and stable Required/Suggested ids, affected files, actual verification output, each id's `direct-fix` or `verified-dependency` disposition and exact evidence, roadmap carryover, routing signals, `REVIEW_<PARENT_TAG>`, and predicted current-pair archive names. Keep current active paths only as evidence pointers. Omit prior lane, grade, routing score, rationale, filename, and preferred next route from routing input. The plan may use current archive names only after routing to render `Archive Evidence Snapshot`.
- `prepare-follow-up` must return `status: routed`, the exact routed basenames, `prepared_plan`, `prepared_review`, `plan_number`, `current_plan_archive_name`, `current_plan_archive_number`, `current_review_archive_name`, `current_review_archive_number`, `plan_log_number`, `review_log_number`, and `gitignore_repair_needed`. It must have executed `finalize-task-routing` in `isolated-reassessment` mode.
- Verify that the returned current archive names/numbers equal the values derived before preparation, and that `plan_log_number` / `review_log_number` are the post-archive counts embedded in the new review stub for its future archive.
- Before archiving either active file, inspect `prepared_plan` directly. Require one non-empty `Finding Resolution Map` that maps every Required/Suggested id exactly once; every `direct-fix` target appears in `Modified Files Summary`; every `verified-dependency` has the exact task-protocol/current-state proof above; and a planned change or satisfied dependency makes repeated verification meaningful. Reuse the existing review/plan analysis; do not add a separate model pass.
- If preparation returns `needs_evidence`, collect all named new evidence and rerun after the input changes; never rerun with unchanged evidence. If the evidence cannot be obtained in the current scope, leave the verdict-appended pair in place and report the exact finalization blocker.
- If preparation returns `blocked`, leave the verdict-appended active PLAN/CODE_REVIEW pair in place, do not check archive/next-state items, and report a resumable finalization blocker. A later code-review invocation resumes this step without appending another verdict.
- If preparation returns `blocked` or the direct prepared-PLAN check fails, correct the handoff/plan from already collected evidence in the same review pass. If exact new evidence is genuinely required, leave the verdict-appended active PLAN/CODE_REVIEW pair in place, do not check archive/next-state items, and report a resumable internal finalization blocker. A later code-review invocation resumes this step without appending another verdict; this condition is not user review by itself.
- For `dependency-wait`, keep one `### Finalization State` under the verdict with status, next task, resume condition, and checked state. Do no work while unchanged; when satisfied, mark it resolved with evidence and resume without another verdict.
After the required next state is prepared, archive is mandatory for `PASS`, `WARN`, and `FAIL`. Ensure `.gitignore` has the Agent-Ops managed gitignore block for task artifacts before writing `*.log` outputs. Prefer `source agent-ops/bin/ai-ignore.sh && agent_ops_ensure_gitignore_task_artifact_block .gitignore`; if the helper is unavailable, add or update a block containing `!agent-task/`, `!agent-task/**/`, `!agent-task/**/*.md`, `!agent-task/**/*.log`, and `agent-roadmap/current.md`. Apply the repair here when `prepare-follow-up` returned `gitignore_repair_needed: true`.
@ -249,16 +260,18 @@ Complete log template:
- Template path: `agent-ops/skills/common/code-review/templates/complete-log-template.md`
- Copy the template's section order and fill every placeholder from the archived plan/review logs and final verdict.
- Do not leave placeholders in `complete.log`.
- Copy the archived PLAN's exact first-line generation header to the first line of `complete.log`. For `m-*`, this preserves the non-empty `milestone-task` ids; for non-milestone work it preserves the ordinary `task/plan/tag` header.
- If the task did not close through `USER_REVIEW.md`, remove the optional user-review row from the `루프 이력` table.
- If the archived plan or review log contains `Roadmap Targets`, copy it into `complete.log` as `Roadmap Completion`. Include the Milestone path, completed Task ids, archived plan/review log paths, and verification evidence. If there is no `Roadmap Targets` section, remove the optional `Roadmap Completion` template section entirely and do not invent roadmap targets.
- Use `없음` for empty `잔여 Nit` or `후속 작업`.
- A PASS `complete.log` must not contain unresolved Required or Suggested issues. Nit-only leftovers may be recorded under `잔여 Nit`.
- The `milestone-task` field is contribution scope, not a completion assertion. Do not write a new `Roadmap Completion` section or claim that any listed Task id is complete merely because this review passed.
For `WARN` or `FAIL`, materialize the next state prepared in Step 5 immediately after archive:
- If the user-review gate triggered, write the prepared body to `agent-task/{task_name}/USER_REVIEW.md`. It must use only `milestone-lock`, contain every archived loop entry plus the exact linked decision, and contain no placeholder. Do not write active PLAN/CODE_REVIEW files or `complete.log`.
- If the user-review gate triggered, write the prepared body to `agent-task/{task_name}/USER_REVIEW.md`. It must use exactly one supported type, `milestone-lock` or `external-execution`, contain every archived loop entry plus the exact required user action or decision, and contain no placeholder. Do not write active PLAN/CODE_REVIEW files or `complete.log`.
- Otherwise write `prepared_plan` and `prepared_review` byte-for-byte to their routed basenames. Do not rerun, adjust, compare, or upgrade their lane/G after archive.
- Verify the written follow-up pair contains the predicted archived plan/review paths in identical `Archive Evidence Snapshot` sections and contains no unresolved token from the review-stub template inventory. Unrelated braces in commands or code are allowed.
- Read the written PLAN once and confirm byte-for-byte materialization retained the checked `Finding Resolution Map` and `Modified Files Summary` invariants.
- Do not adjust the prepared route after finalization. For a `local-fit` base, `review_rework_count >= 2` or `evidence_integrity_failure=true` must produce `recovery-boundary`; `capability-gap` and `grade-boundary` keep their own basis.
If the task group is `m-<milestone-slug>` and the user-review gate triggered, report that the milestone task is blocked on user review; do not emit PASS completion metadata and do not call `update-roadmap`.
@ -270,11 +283,11 @@ After Step 6:
- If verdict is `PASS`, determine archive month from the current completion date as `YYYY/MM`, create the needed archive parent directories, then move the selected task artifacts from `agent-task/{task_name}/` to `agent-task/archive/YYYY/MM/{task_name}/`. For split work, move the selected subtask directory itself and preserve the task group path, e.g. `agent-task/refactoring/01_core/` moves to `agent-task/archive/YYYY/MM/refactoring/01_core/`.
- Do not overwrite an existing archive directory. If `agent-task/archive/YYYY/MM/{task_name}/` already exists, append the next numeric suffix to the final path segment: single-plan `agent-task/archive/YYYY/MM/{task_group}_1/`, split-plan `agent-task/archive/YYYY/MM/{task_group}/{subtask_dir}_1/`, and so on.
- After moving a split subtask, remove the active parent `agent-task/{task_group}/` only when it is empty.
- If verdict is `PASS` and `{task_group}` matches `m-<milestone-slug>`, do not resolve the roadmap target and do not call `update-roadmap`. Report completion event metadata after the task archive move: `origin-task=agent-task/{task_name}` from the original active task path, `task-group={task_group}`, `milestone-slug=<milestone-slug>`, final archive path, `complete.log` path, archived plan/review log paths, and `roadmap-completion=<Task ids from complete.log or none>`.
- The runtime consumes that completion event, checks current state, and calls `update-roadmap` if needed. `update-roadmap` only checks Milestone Task ids when `complete.log` contains `Roadmap Completion`; if the section is absent, roadmap Task completion is a no-op even for `m-*` task groups.
- If verdict is `PASS` and `{task_group}` matches `m-<milestone-slug>`, do not resolve the roadmap target and do not call `update-roadmap`. Report completion event metadata after the task archive move: `origin-task=agent-task/{task_name}` from the original active task path, `task-group={task_group}`, `milestone-slug=<milestone-slug>`, final archive path, `complete.log` path, archived plan/review log paths, and `milestone-task=<ids copied from the header>`.
- The runtime consumes that completion event and invokes `sync-milestone-workstate target-milestone=<milestone-slug> complete-log=<path>`. The sync skill scans every same-group active/archive `complete.log`, aggregates evidence by the listed ids, and checks only Tasks whose full current contract is satisfied.
- `WARN` and `FAIL` do not update the roadmap Milestone; the follow-up plan remains under the same `m-<milestone-slug>` task group when the original task was Milestone-linked.
- `USER_REVIEW` does not update the roadmap Milestone and does not produce PASS completion metadata. Keep the active task directory in place with `USER_REVIEW.md` and archived plan/review logs until the linked Milestone lock decision is resolved.
- If `USER_REVIEW.md` is later resolved as complete/PASS by linked Milestone decision and evidence, write `complete.log`, move the task artifacts to archive, and report `m-*` PASS completion metadata just like a normal `PASS`.
- `USER_REVIEW` does not update the roadmap Milestone and does not produce PASS completion metadata. Keep the active task directory in place with `USER_REVIEW.md` and archived plan/review logs until its recorded user action or decision is resolved.
- If `USER_REVIEW.md` is later resolved as complete/PASS by the recorded action or decision and evidence, write `complete.log`, move the task artifacts to archive, and report `m-*` PASS completion metadata just like a normal `PASS`.
- For `PASS`, open the moved `agent-task/archive/YYYY/MM/{final_task_name}/{current_review_archive_name}`, where `{final_task_name}` is the archived task path, including `{task_group}/` for split work.
- For user-review-resolved PASS, confirm the moved archive contains the resolved `USER_REVIEW.md`, `complete.log`, and the existing archived `plan_*.log` / `code_review_*.log`; do not recreate an active review file only to add a new checklist item.
- For `WARN` or `FAIL`, open `agent-task/{task_name}/{current_review_archive_name}`.
@ -309,18 +322,21 @@ Report Required/Suggested counts, archive names, the final task archive path for
## Final Checklist
For `status=blocked`, keep the verdict pair and persist/report blocker, next task, and resume condition. Archive/next-state items below wait until it changes; unchanged dependency state triggers no review work.
- `{current_review_archive_name}` exists with the verdict appended and was derived from the archived active review's own route.
- `{current_plan_archive_name}` exists and was derived from the archived active plan's own route.
- `.gitignore` unignores `agent-task/**/*.md` and `agent-task/**/*.log` and ignores local `agent-roadmap/current.md`; generated task artifacts are not ignored by `git check-ignore`.
- No active `PLAN-*.md`, `CODE_REVIEW-*.md`, or `USER_REVIEW.md` remains after PASS or user-review-resolved PASS.
- PASS or user-review-resolved PASS: `complete.log` written from `agent-ops/skills/common/code-review/templates/complete-log-template.md`, then task artifacts moved under `agent-task/archive/YYYY/MM/` with task-group path preserved for split work.
- PASS milestone task group: `m-<milestone-slug>` completion event metadata was reported for runtime; roadmap was not modified by code-review.
- PASS with `Roadmap Targets`: `complete.log` contains `Roadmap Completion` with Milestone path, Task ids, archived plan/review evidence, and verification evidence.
- PASS without `Roadmap Targets`: `complete.log` omits `Roadmap Completion` and reported metadata says `roadmap-completion=none`.
- PASS `complete.log` first line is byte-for-byte identical to the archived PLAN header. An `m-*` log contains non-empty `milestone-task` ids and reports them in completion metadata; a non-milestone log omits the field.
- PASS does not create `Roadmap Completion` or directly check a Milestone Task. Aggregated evaluation is deferred to `sync-milestone-workstate`.
- WARN/FAIL without user-review gate: the plan skill was invoked for the exact task path with verified `review_rework_count` and `evidence_integrity_failure`, completed `finalize-task-routing`, and created new active `PLAN-{build_lane}-GNN.md` and `CODE_REVIEW-{review_lane}-GNN.md` files matching the fresh routed output; no `complete.log`.
- WARN/FAIL follow-up: the plan input omitted prior route fields, revalidated outcome/acceptance/exclusions from current evidence, used the completed in-memory PLAN as the packet, and copied identical `Archive Evidence Snapshot` sections into the new plan/review pair.
- WARN/FAIL prepared PLAN passed the intrinsic finding-resolution and write-boundary checks before active-pair archive and retained them after byte-for-byte materialization. Invalid resolution or write claims leave the verdict-appended prior pair active.
- WARN/FAIL follow-up: the plan input omitted prior route fields, revalidated outcome/acceptance/exclusions from current evidence, mapped every stable Required/Suggested id to a direct fix or exact verified dependency, used the completed in-memory PLAN as the packet, and copied identical `Archive Evidence Snapshot` sections into the new plan/review pair.
- Follow-up plans and review stubs keep implementation agents limited to implementation/test/evidence and contain no implementation-owned user-review request section.
- USER_REVIEW: `USER_REVIEW.md` exists from template, no active `PLAN-*.md` or `CODE_REVIEW-*.md` remains, and no `complete.log` was written.
- Review-agent-owned USER_REVIEW: the generated `USER_REVIEW.md` records the exact active Milestone decision and repository evidence that made automatic continuation unsafe.
- Review-agent-owned USER_REVIEW: the generated `USER_REVIEW.md` records one supported gate type, the exact Milestone decision or external-execution user action, and evidence that made automatic continuation unsafe.
- USER_REVIEW resolved as PASS: archived task contains both resolved `USER_REVIEW.md` and `complete.log`.
- The applicable review-agent-only finalization checklist was completed before reporting.

View file

@ -1,4 +1,4 @@
interface:
display_name: "Code Review"
short_description: "Review and route task loops"
default_prompt: "Use $code-review to review or resolve the active task, archive it, and on WARN/FAIL invoke $plan so $finalize-task-routing creates the next routed pair."
default_prompt: "Use $code-review to review or resolve the active task, archive it, and on WARN/FAIL either create a justified milestone-lock/external-execution USER_REVIEW stop or invoke $plan for the next routed pair."

View file

@ -1,3 +1,5 @@
<!-- task={task_name} plan={plan_number} tag={TAG}{milestone_task_metadata_or_omit} -->
# Complete - {task_name}
## 완료 일시
@ -13,7 +15,7 @@
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_{build_lane}_GNN_N.log` | `code_review_{review_lane}_GNN_N.log` | PASS/WARN/FAIL | {main outcome or follow-up reason} |
| `USER_REVIEW.md` | linked Milestone decision | PASS/RESOLVED | {only when a user-review stop was resolved as the terminal completion path; remove this row otherwise} |
| `USER_REVIEW.md` | recorded user action or decision | PASS/RESOLVED | {only when a user-review stop was resolved as the terminal completion path; remove this row otherwise} |
## 구현/정리 내용
@ -23,16 +25,6 @@
- `{command}` - {PASS/FAIL/BLOCKED}; {actual output summary or saved output path}
## Roadmap Completion
{optional; include only when archived plan/review had Roadmap Targets. Remove this entire section when there are no Roadmap Targets.}
- Milestone: `{agent-roadmap/phase/<phase-slug>/milestones/<milestone-slug>.md}`
- Milestone link: [Milestone 문서](agent-roadmap/phase/<phase-slug>/milestones/<milestone-slug>.md)
- Completed task ids:
- `{task-id}`: PASS; evidence=`{archived-plan-log}`, `{archived-review-log}`; verification=`{command or saved output path}`
- Not completed task ids: 없음
## 잔여 Nit
- 없음

View file

@ -1,50 +1,50 @@
# User Review Required - {task_name}
## 요청 일시
## Requested At
{YYYY-MM-DD or ISO-8601}
## 상태
## Status
USER_REVIEW
## 사유
## Reason
- 유형: milestone-lock
- 연결 대상: {agent-roadmap/phase/<phase>/milestones/<milestone>.md}
- 현재 리뷰 회차: {review-number}
- 최종 판정: {WARN or FAIL}
- 요약: {Milestone 구현 잠금 항목이 실구현을 차단한 이유}
- Type: {milestone-lock | external-execution}
- Target: {agent-roadmap/phase/<phase>/milestones/<milestone>.md | exact runner/device/service/access target}
- Current review number: {review-number}
- Final verdict: {WARN or FAIL}
- Summary: {why the Milestone decision or user-controlled external execution blocks the next safe step}
## 루프 이력
## Loop History
| Plan | Review | Verdict | 메모 |
| Plan | Review | Verdict | Note |
|------|--------|---------|------|
| `{plan-log-0}` | `{code-review-log-0}` | {PASS/WARN/FAIL/unknown} | {main issue or blocking reason} |
| `{current-archived-plan-log}` | `{current-archived-review-log}` | {WARN/FAIL} | {main issue or blocking reason} |
## 차단 근거
## Blocking Evidence
- 문제: {review finding summary}
- 현재 archive plan: `{current-archived-plan-log}`
- 현재 archive review: `{current-archived-review-log}`
- 검증 명령: `{command or 없음}`
- 실제 출력: {stdout/stderr excerpt or saved output path}
- 차단 판단 근거: {연결 대상의 Milestone 결정 필요 항목과 일치하는 근거}
- Problem: {review finding summary}
- Current archived plan: `{current-archived-plan-log}`
- Current archived review: `{current-archived-review-log}`
- Verification command: {command or none}
- Actual output: {stdout/stderr excerpt or saved output path}
- Blocking rationale: {evidence matching the Milestone decision | evidence that the declared runner/transport was checked but automatic execution remains unsafe without user action}
## 연결 결정 필요
## Required User Action
- [ ] {Milestone `구현 잠금 > 결정 필요`에 기록된 결정 항목}
- [ ] {Milestone `구현 잠금 > 결정 필요` item | exact access/authorization/environment/evidence action}
## 재개 조건
## Resume Condition
- {위 결정이 Milestone 구현 잠금에 반영되고, 필요한 경우 해당 USER_REVIEW.md가 user_review_N.log로 해결되어야 한다}
- {concrete evidence proving the required action or decision is resolved, plus the next review/plan entry condition}
## 다음 실행 힌트
## Next Execution Hint
- {resolve-review 또는 update-roadmap으로 반영할 대상 경로와 후속 plan/review 재개 조건}
- {the correct resolve-review, update-roadmap, or external-verification replan entry point and target path}
## 종료 규칙
## Closure Rules
- 연결 결정과 evidence가 이 stop state를 완료/PASS로 해소하면 `USER_REVIEW.md`를 해소 상태로 갱신하고, `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
- 새 구현이 필요하면 `plan` 스킬이 `USER_REVIEW.md``user_review_N.log`로 아카이브한 뒤 새 `PLAN-*-G??.md` / `CODE_REVIEW-*-G??.md`를 작성한다.
- If the recorded user action and evidence resolve this stop as complete/PASS, update `USER_REVIEW.md` to the resolved state, write `complete.log` from `agent-ops/skills/common/code-review/templates/complete-log-template.md`, and move the task directory to the archive.
- If new implementation is required, the `plan` skill archives `USER_REVIEW.md` as `user_review_N.log` before writing a new `PLAN-*-G??.md` / `CODE_REVIEW-*-G??.md` pair.

View file

@ -80,7 +80,7 @@ description: "마일스톤 완료해도 될지 검토, 현 마일스톤 종료
7. **다음 Milestone 지정**
- `next-milestone`이 있으면 활성 Milestone에서 정확히 하나인지 확인하고 `current.md`에 반영한다.
- 없고 `priority-queue.md`가 있으면 큐의 위에서 아래 순서 중 완료/archive 대상이 아니고 `[폐기]`가 아닌 첫 활성 Milestone을 다음 후보로 찾는다.
- 없고 `priority-queue.md`가 있으면 각 prefix의 가장 작은 active index 중 `선행 차단`이 없고, 현재 진행 중인 `동시 차단` target이 없는 lane head를 다음 후보로 찾는다. 여러 후보가 열리면 방금 완료한 prefix의 다음 항목을 우선하고, 없으면 queue group 순서를 tie-breaker로 쓴다.
- `priority-queue.md`가 없으면 같은 Phase의 Milestone 흐름에서 완료/폐기/archive가 아닌 다음 후보를 찾는다.
- 후보가 없거나 둘 이상이면 자동 지정하지 않고 후보와 이유를 보고한다.
- 다음 후보가 `[스케치]`이면 구현 대상이 아니라 구체화 대상임을 보고한다.
@ -98,7 +98,7 @@ description: "마일스톤 완료해도 될지 검토, 현 마일스톤 종료
- [ ] `agent-spec/`가 있으면 `update-spec` 결과를 확인하고 완료 진행 가능 상태가 `Spec updated` 또는 `Spec update not needed`인지 판단했는가
- [ ] `Spec blocked` 또는 `create-spec needed` 상태에서 Milestone 완료/archive를 하지 않았는가
- [ ] `update-roadmap` 완료/archive 규칙과 workspace lock 규칙을 따랐는가
- [ ] `priority-queue.md`가 있으면 완료/archive된 Milestone이 제거되었고 다음 후보 판단에 큐 순서가 반영되었는가
- [ ] `priority-queue.md`가 있으면 완료/archive된 Milestone과 충족된 blocker 참조가 제거되었고 다음 후보 판단에 prefix index와 차단 예외가 반영되었는가
- [ ] 완료 Milestone이 `current.md`에 남아 있지 않은가
- [ ] 다음 Milestone 지정이 모호하면 자동 선택하지 않았는가
- [ ] `git diff --check`를 실행했는가

View file

@ -86,7 +86,7 @@ README는 프로젝트 특성에 맞게 필요한 섹션만 사용하되, 기본
## 먼저 확인할 것
- [ ] 루트 `README.md` 존재 여부와 기존 내용 확인
- [ ] `package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`, `Makefile`, `docker-compose.yml` 실행/검증 명령 근거 확인
- [ ] 프로젝트 manifest, build 설정, container 설정, CI workflow 등에서 실행/검증 명령 근거 확인
- [ ] `agent-ops/rules/project/rules.md`가 있으면 프로젝트 개요, 기술 스택, 도메인 매핑 확인
- [ ] `agent-roadmap/ROADMAP.md` 또는 로컬 `agent-roadmap/current.md`가 있으면 제품 방향과 활성 Milestone 문서 경로만 확인
- [ ] 주요 소스 디렉터리와 테스트 디렉터리를 `rg --files`로 가볍게 확인

View file

@ -9,7 +9,7 @@ description: AI-first 개인/소규모 프로젝트의 전체 목표, Phase scaf
`agent-roadmap/` 하위에 `Roadmap -> priority-queue -> Phase -> Milestone` 기반 한국어 로드맵 구조를 처음 생성한다.
전체 로드맵은 전체 방향과 Phase index만 담당하고, 일반 작업에서는 브랜치별 로컬 `current.md`의 활성 Phase/Milestone 링크와 관련 문서만 읽도록 만든다.
`priority-queue.md`는 Phase를 가로지르는 실행 순서만 담당한다.
`priority-queue.md`는 Phase를 가로지르는 prefix별 실행 순서와 막히는 지점의 차단 예외만 담당한다.
Milestone은 구현 계획이 아니라 방향성, 범위, 위험, 확인 필요 사항을 기록하는 협업 문서다.
Epic과 Task는 별도 파일로 분리하지 않고 Milestone 문서의 `기능` 안에서 관리한다. 별도 `완료 기준` 섹션은 만들지 않고, 검증이 필요한 기능에만 같은 Task 안의 `검증:` 문구로 통합한다.
@ -59,7 +59,7 @@ agent-roadmap/
| 파일 | 역할 |
|------|------|
| `agent-roadmap/ROADMAP.md` | 전체 목표와 Phase 흐름만 담는 최상위 지도. 로드맵 생성/갱신/Phase 전환 때만 읽는다 |
| `agent-roadmap/priority-queue.md` | Phase를 가로지르는 Milestone 실행 순서만 담는 순서 전용 문서 |
| `agent-roadmap/priority-queue.md` | Phase를 가로지르는 prefix별 Milestone 순서, 설명, 차단 예외를 담는 실행 순서 문서 |
| `agent-roadmap/current.md` | 활성 Phase와 활성 Milestone 후보, 선택 규칙을 담는 브랜치별 로컬 포인터 |
| `agent-roadmap/phase/<phase-slug>/PHASE.md` | Phase 목표, 상태, Milestone 흐름, Phase 경계를 담는 문서 |
| `agent-roadmap/phase/<phase-slug>/milestones/<milestone-slug>.md` | 일반 작업 시 읽는 Milestone 단위 목표, 스케치 승격 조건, 구현 잠금, 범위, 기능 Epic/Task 체크리스트, 범위 제외 항목 |
@ -78,7 +78,7 @@ agent-roadmap/
- SDD 본문은 `agent-ops/skills/common/_templates/roadmap-sdd-template.md` 형식을 따른다.
- SDD 사용자 리뷰는 `agent-ops/skills/common/_templates/roadmap-sdd-user-review-template.md` 형식을 따른다.
- `ROADMAP.md`에는 Milestone 상세 체크리스트를 넣지 않는다.
- `priority-queue.md`순서 전용 문서이며, Milestone 제목 링크와 식별용 한 줄 설명만 둔다.
- `priority-queue.md``[prefix-NN]` 제목 링크, 1~2문장 설명, 필요한 `선행 차단`/`동시 차단`만 둔다.
- `current.md`는 git 추적 대상이 아니며, 예시 파일을 `agent-roadmap/`에 따로 만들지 않는다.
- `current.md`는 활성 Phase/Milestone 후보만 담고, 개인별 현재 작업 위치나 완료 상태를 적지 않는다.
- archive 경로는 `current.md`의 활성 항목에 넣지 않는다.
@ -89,15 +89,17 @@ agent-roadmap/
- 상태 표기는 `[스케치]`, `[계획]`, `[진행중]`, `[검토중]`, `[완료]`, `[보류]`, `[폐기]` 중 하나만 사용한다.
- `[스케치]`는 방향성, 문제의식, 후보 범위, 미정 질문을 기록하는 컨셉 상태다. 구현 가능한 계획이 아니므로 구현 계획 생성 대상이 아니다.
- `[계획]`은 목표, 범위, 기능 Task, 구현 잠금, 결정 필요 항목이 문서화되어 잠금 해제 후 구현 계획을 만들 수 있는 상태다.
- Phase와 Milestone 이름, 파일명에는 `1`, `2`, `M01`, `P1` 같은 순번을 붙이지 않는다.
- Phase 이름과 파일명, Milestone 파일명에는 `1`, `2`, `M01`, `P1` 같은 순번을 붙이지 않는다. Milestone 표시 제목에는 실행 태그 `[prefix-NN]`을 반드시 붙인다.
- Phase 흐름은 실행 순서가 아니라 도메인/책임 영역의 구조적 지도다.
- Phase를 가로지르는 실제 실행 순서는 `priority-queue.md`위에서 아래 순서로 표현한다.
- `priority-queue.md`에는 상태, 목표, 범위, 잠금, 기능, 완료 근거, 의존성 필드를 복제하지 않는다.
- Phase를 가로지르는 실제 실행 순서는 `priority-queue.md`prefix 그룹과 두 자리 index로 표현한다. 같은 prefix는 작은 index부터 순차 실행하고, 다른 prefix는 차단 표기가 없으면 병렬 실행할 수 있다.
- `priority-queue.md`에는 상태, 목표, 범위, 잠금, 기능, 완료 근거를 복제하지 않는다. 설명은 생략하지 않고, 일반 순서로 표현할 수 없는 차단 예외만 exact blocker label로 둔다.
- 실행 후보가 없더라도 `priority-queue.md` 파일과 `실행 순서` 섹션은 생성한다.
- Phase 파일명은 `agent-roadmap/phase/<phase-slug>/PHASE.md`로 만든다.
- Milestone 파일명은 `agent-roadmap/phase/<phase-slug>/milestones/<milestone-slug>.md`로 만든다.
- `<phase-slug>``<milestone-slug>`는 소문자 영문, 숫자, 하이픈만 사용하고, 공백/언더스코어/순번 prefix를 넣지 않는다.
- 중간에 Phase나 Milestone을 끼워 넣을 수 있도록 기존 항목의 이름과 파일명을 불필요하게 바꾸지 않는다.
- 실행 태그는 `^[a-z][a-z0-9-]*-[0-9]{2}$` 형태로 만들고 프로젝트 안에서 유일하게 유지한다. 같은 작업 lane은 같은 prefix로 묶고, 관련성만 있고 순차 관계가 없으면 다른 prefix를 사용한다.
- 실행 태그 재배치가 필요하면 파일명은 유지하고 Milestone H1, `PHASE.md`, 로컬 `current.md`, `priority-queue.md`와 queue blocker 참조만 함께 바꾼다.
- 로드맵 문서 안에서 다른 로드맵 문서, SDD, `USER_REVIEW.md`, archive 문서를 가리킬 때는 raw path만 쓰지 말고 `[표시 제목](상대경로)` Markdown 링크로 쓴다.
- 문서 안의 링크 target은 링크를 작성하는 Markdown 파일 위치 기준 상대경로로 쓴다. 예: `ROADMAP.md``current.md`에서는 `[PHASE.md](phase/<phase-slug>/PHASE.md)`, Phase 문서에서는 `[Milestone](milestones/<milestone-slug>.md)`, Milestone 문서에서는 `[ROADMAP.md](../../../ROADMAP.md)``[PHASE.md](../PHASE.md)`를 쓴다.
- `경로:`, `Phase:`, `Milestone:`, `SDD 문서:` 같은 필드명을 유지해야 해도 값은 `경로: [PHASE.md](phase/<phase-slug>/PHASE.md)`처럼 링크로 작성한다.
@ -107,6 +109,7 @@ agent-roadmap/
## Milestone 작성 규칙
- Milestone 기본 섹션은 `위치`, `목표`, `상태`, `구현 잠금`, `범위`, `기능`, `완료 리뷰`, `범위 제외`, `작업 컨텍스트`다.
- Milestone H1은 `# Milestone: [prefix-NN] <Milestone 이름>` 형식으로 작성한다. scheduling 관계는 `priority-queue.md`를 원본으로 두고 Milestone `작업 컨텍스트`에는 queue 링크만 둔다.
- `승격 조건``[스케치]` Milestone에서 필수다. `[계획]` 이상 상태에서는 섹션을 생략하거나 `- 없음`으로 둘 수 있다.
- `[스케치]` Milestone은 `승격 조건`을 체크리스트로 작성하고, `[계획]`으로 전환하기 위해 필요한 정의, 결정, 경계, 후속 구현 Milestone 후보를 적는다.
- 새 Milestone은 에이전트가 확정할 수 없는 제품 방향, 범위, 우선순위, 책임 경계가 남아 있으면 `구현 잠금``잠금`으로 둔다.
@ -159,7 +162,7 @@ agent-roadmap/
4. **파일 생성**
- `ROADMAP.md`, `priority-queue.md`, 로컬 `current.md`, 각 Phase의 `PHASE.md`, 각 Milestone 문서를 템플릿 순서대로 생성한다.
- `ROADMAP.md` 최상단에는 `priority-queue.md`를 먼저 확인하도록 `고정 실행 순서` 섹션을 둔다.
- `priority-queue.md`에는 Phase를 가로지르는 실행 후보 Milestone을 위에서 아래 순서로 둔다. 후보가 없으면 `실행 순서` 섹션만 비워 둔다.
- `priority-queue.md`에는 실행 후보를 prefix별 `### <prefix>` 그룹으로 묶고 index 오름차순으로 둔다. 각 항목의 설명 1~2문장을 유지하고, 다른 prefix의 필수 선행은 `선행 차단`, 실제 동시 충돌은 낮은 우선순위 쪽 `동시 차단`으로만 둔다. 후보가 없으면 `실행 순서` 섹션만 비워 둔다.
- 활성 Phase와 활성 Milestone은 `current.md`에 모두 기록한다.
- `.gitignore`의 Agent-Ops 관리 block에 `agent-roadmap/current.md`가 있는지 확인하고 없으면 추가한다.
- `ROADMAP.md`의 Phase 흐름에는 완료/검토중/진행중/계획/스케치 Phase 모두를 상태 그룹별로 정리한다. 이 순서는 실행 우선순위가 아니며, 실제 다음 작업 선택은 `priority-queue.md`를 따른다.
@ -173,7 +176,7 @@ agent-roadmap/
5. **검증**
- 생성한 링크가 실제 파일을 가리키는지 확인한다.
- `priority-queue.md`가 존재하고 `실행 순서` 섹션이 있는지 확인한다.
- `priority-queue.md`의 모든 링크가 활성 Milestone 파일을 가리키는지 확인한다. 실행 후보가 없어서 빈 큐이면 정상으로 보고하되, 활성 실행 후보가 있는데 비어 있으면 큐 동기화 필요로 본다.
- `priority-queue.md`의 모든 링크가 활성 Milestone 파일을 가리키고, 실행 태그가 유일하며, group prefix와 tag prefix가 일치하고, 같은 prefix index가 오름차순인지 확인한다. blocker는 다른 active tag만 참조하고 같은 prefix 정상 순서를 중복하지 않아야 한다. 실행 후보가 없어서 빈 큐이면 정상으로 보고하되, 활성 실행 후보가 있는데 비어 있으면 큐 동기화 필요로 본다.
- 생성한 로드맵 문서의 문서/산출물 포인터가 raw path만 남지 않고 Markdown 링크로 작성되었는지 확인한다.
- 생성한 로드맵 문서의 Markdown 링크 target에 템플릿 placeholder가 남지 않았는지 확인한다.
- 로컬 `current.md` 활성 항목에 `agent-roadmap/archive/**` 경로가 없는지 확인한다.
@ -221,11 +224,11 @@ agent-roadmap/
- 기존 `agent-roadmap/` 파일을 덮어쓰지 않는다.
- 일반 작업마다 전체 `ROADMAP.md`를 읽도록 규칙을 만들지 않는다.
- `priority-queue.md`를 두 번째 로드맵처럼 사용하지 않는다. 상태, 목표, 범위, 잠금, 기능, 완료 근거를 복제하지 않는다.
- `priority-queue.md`를 두 번째 로드맵처럼 사용하지 않는다. 상태, 목표, 범위, 잠금, 기능, 완료 근거를 복제하지 않되, 항목 설명과 최소 차단 예외는 생략하지 않는다.
- `priority-queue.md`에 archive Milestone 링크를 넣지 않는다.
- 로컬 `current.md``agent-roadmap/archive/**` 경로를 넣지 않는다.
- `agent-roadmap/current.md`를 git 추적 대상으로 만들지 않는다.
- Phase와 Milestone 이름 또는 파일명에 순번을 강제하지 않는다.
- Phase 이름/파일명이나 Milestone 파일명에 순번을 강제하지 않는다. Milestone 표시 제목의 `[prefix-NN]` 실행 태그는 예외다.
- `ROADMAP.md`에 Milestone 상세 작업 체크리스트를 넣지 않는다.
- Epic과 Task를 별도 파일로 분리하지 않는다.
- Milestone 문서에서 `구현 잠금` 섹션을 생략하지 않는다.

View file

@ -1,109 +1,116 @@
---
name: create-skill
version: 1.0.1
description: 새로운 SKILL.md 파일을 생성하기 위한 범용 스킬
version: 1.1.0
description: Create an agent-ops skill with one responsibility, a minimal execution contract, unambiguous English instructions, and correct routing. Use when adding a new common, project, or private SKILL.md.
---
# Create Skill
## 목적
## Purpose
`agent-ops/skills/` 하위에 올바른 형식의 SKILL.md 파일을 생성한다.
기존 skill-template.md 를 기반으로, 요청 목적에 맞는 내용을 채워 넣는다.
생성 후 라우팅 항목을 추가한다.
Create a correctly structured `SKILL.md` under `agent-ops/skills/` from the current skill template, then register its routing entry.
이 스킬은 프로젝트 내부 `agent-ops` 라우터가 읽는 스킬을 만든다.
`$CODEX_HOME/skills`에 설치되어 Codex가 직접 discover하는 스킬을 만들 때는 시스템 `skill-creator` 규칙을 우선하고, frontmatter는 `name``description`만 사용한다.
This skill creates skills read by the project-local `agent-ops` router. When creating a Codex-discoverable skill under `$CODEX_HOME/skills`, follow the system `skill-creator` rules instead and use only `name` and `description` in the frontmatter.
### 생성 위치 결정
- `.agent-ops-source` 파일이 **있으면** (공통 관리 레포): `agent-ops/skills/common/<skill-name>/SKILL.md`
- `.agent-ops-source` 파일이 **없고** 사용자가 private 또는 operator-local을 명시하면 (타겟 프로젝트): `agent-ops/skills/private/<skill-name>/SKILL.md`
- `.agent-ops-source` 파일이 **없고** private 요청이 없으면 (타겟 프로젝트): `agent-ops/skills/project/<skill-name>/SKILL.md`
### Select the creation location
## 언제 호출할지
- If `.agent-ops-source` exists, create `agent-ops/skills/common/<skill-name>/SKILL.md`.
- If `.agent-ops-source` does not exist and the user explicitly requests private or operator-local visibility, create `agent-ops/skills/private/<skill-name>/SKILL.md`.
- If `.agent-ops-source` does not exist and the user does not request private visibility, create `agent-ops/skills/project/<skill-name>/SKILL.md`.
- 새로운 반복 작업 패턴이 생겨 skill로 정의해야 할 때
- 기존 skill이 없는 작업 유형을 처음 수행하기 전에
- 사용자가 특정 작업을 skill로 만들어 달라고 요청할 때
## When to use
## 입력
- A repeatable task pattern is not covered by an existing skill.
- The user asks to create a specific skill.
- `skill-name`: 생성할 skill 이름, kebab-case (필수)
- `purpose`: 이 skill이 해결하는 문제 한 줄 요약 (필수)
- `visibility`: `common`, `project`, `private` 중 하나. 사용자가 private 또는 operator-local을 명시했을 때만 `private`을 선택한다. (선택)
- `trigger-cases`: 이 skill을 호출해야 하는 상황 목록 (선택)
## Inputs
## 먼저 확인할 것
- `skill-name`: Kebab-case skill name. (required)
- `purpose`: One-sentence summary of the problem the skill solves. (required)
- `visibility`: `common`, `project`, or `private`. Select `private` only when the user explicitly requests private or operator-local visibility. (optional)
- `trigger-cases`: Situations that should invoke the skill. (optional)
- [ ] `agent-ops/skills/common/`, `agent-ops/skills/project/`, `agent-ops/skills/private/` 하위에 동일 이름의 디렉터리가 이미 존재하는지 확인
- [ ] `agent-ops/skills/common/router.md``agent-ops/rules/project/rules.md` 에 이미 유사한 라우팅 항목이 있는지 확인
- [ ] `agent-ops/skills/common/_templates/skill-template.md` 를 읽어 최신 템플릿 형식 파악
## Preflight
## 실행 절차
- [ ] Check `agent-ops/skills/common/`, `agent-ops/skills/project/`, and `agent-ops/skills/private/` for an existing directory with the same name.
- [ ] Check `agent-ops/skills/common/router.md` and `agent-ops/rules/project/rules.md` for equivalent routing or functionality.
- [ ] Read `agent-ops/skills/common/_templates/skill-template.md` for the current structure.
1. **중복 확인**
- 같은 visibility 경로에 이미 있는 skill은 덮어쓰지 않고 중단한다.
- private 요청에서 같은 이름의 project skill은 의도된 override 후보이므로 중복으로 중단하지 않는다. common skill과의 같은 이름 또는 다른 기능의 중복은 사용자에게 알리고 중단한다.
- private override가 아닌 기능 중복은 사용자에게 알리고 중단한다.
- project skill과 같은 이름의 private override는 해당 project skill의 책임을 완전히 대체하는지 확인한다.
## Procedure
2. **목적 분석**
- `purpose``trigger-cases` 를 바탕으로 아래 항목을 도출한다
- 언제 호출할지 (2~4개)
- 필요한 입력 파라미터
- 사전 확인 항목
- 실행 절차 (3~7단계)
- 출력 형식
- 금지 사항
1. **Reject unintended duplication**
- Stop instead of overwriting a skill in the same visibility path.
- For a private request, treat a same-name project skill as a possible intentional override rather than an automatic duplicate. Stop and report a same-name common skill or a functional duplicate.
- Confirm that a private override fully replaces the responsibility of its same-name project skill.
3. **SKILL.md 생성**
- 경로: 생성 위치 결정 규칙에 따라 `common/`, `project/`, 또는 `private/` 하위에 생성
- `skill-template.md` 형식을 따른다
- agent-ops 내부 스킬은 기존 로컬 관례에 맞춰 `version`을 둘 수 있다. Codex 설치형 스킬로 배포할 목적이면 `version`이나 `depends` 같은 비표준 frontmatter를 넣지 않는다.
- 프로젝트 특화 내용보다 범용 절차를 우선한다
- 절차는 구체적이되 지나치게 세부 구현을 기술하지 않는다
2. **Define one responsibility and its minimum contract**
- Select only the representative trigger cases needed to distinguish this skill from existing routes. Do not pad the list to reach a target count.
- Define the required procedure and success or failure criteria. Add inputs, preflight checks, an exact output format, or prohibitions only when they change correct execution or verdict determination.
- Use the fewest procedure steps that preserve the workflow. Three to seven steps are a guideline for a genuinely multi-stage workflow, not a required count.
- Include only contracts required to execute the repeated task and determine success or failure.
- Do not add speculative inputs, states, branches, output fields, validation rules, or extension points for unsupported future cases.
- Omit a rule that does not change an action, selection, validation result, or failure response. Link to an existing owning rule instead of restating its contract.
- Keep exactly one independent responsibility in the skill.
4. **라우팅 업데이트**
- `.agent-ops-source` 마커가 **있으면** (공통 관리 레포): `agent-ops/skills/common/router.md`에 라우팅 항목 추가
- private skill이 같은 이름의 project skill을 override하면 별도 라우팅 항목을 추가하지 않는다. 공통 규칙의 private 우선순위를 사용한다.
- project skill과 짝이 없는 private skill은 `agent-ops/rules/private/rules.md`에만 라우팅 항목을 추가한다. 파일이 없으면 private route만 담은 ignored local rule을 생성한다.
- private rule의 trigger는 project router와 중복 등록하지 않는다.
- `.agent-ops-source` 마커가 **없고** private skill이 아니면 (타겟 프로젝트): `agent-ops/rules/project/rules.md`의 프로젝트 스킬 라우터 섹션에 라우팅 항목 추가
- 기존 공통 스킬을 수정해 trigger가 달라졌다면 새 skill을 만들지 말고 `agent-ops/skills/common/router.md`의 기존 행을 갱신한다
- 이 skill이 속할 라우팅 축(구조 분석/코드 변경/흐름 추적 등)을 판단한다
- 기존 라우팅 구조를 깨지 않는다
3. **Write `SKILL.md`**
- Create the file under the selected `common/`, `project/`, or `private/` path and follow `skill-template.md`.
- Write the frontmatter `description` and Markdown instructions in English.
- Use short imperative sentences with one meaning each. State the condition, required action, and verifiable success or failure criterion when they affect execution.
- Do not use discretionary terms such as `appropriately`, `if needed`, or `when possible` without a decision criterion.
- Keep the procedure specific without encoding unnecessary implementation detail.
- Preserve paths, filenames, IDs, commands, regexes, status values, protocol tokens, and other exact literals. User-facing output literals may use the language required by the project.
- Agent-ops internal skills may retain the local `version` convention. For Codex-installed skills, do not add nonstandard frontmatter such as `version` or `depends`.
- Prefer reusable procedures over project-specific implementation details.
- Remove unused optional template sections, authoring comments, and placeholders from the completed skill.
5. **결과 보고**
- 생성한 파일 경로
- 라우팅 항목을 추가한 파일과 내용
- 이 skill이 다루지 않는 범위(필요 시)
4. **Update routing**
- If `.agent-ops-source` exists, add the routing entry to `agent-ops/skills/common/router.md`.
- Do not add a separate route when a private skill overrides a same-name project skill; use the common private-precedence rule.
- Route a private skill with no project counterpart only from `agent-ops/rules/private/rules.md`. If the file does not exist, create an ignored local rule containing only the private route.
- Do not duplicate a private trigger in the project router.
- If `.agent-ops-source` does not exist and the skill is not private, add the route to the project skill router section in `agent-ops/rules/project/rules.md`.
- If an existing common skill only needs different triggers, update its existing row in `agent-ops/skills/common/router.md` instead of creating another skill.
- Select the existing routing axis that matches the skill and preserve the current routing structure.
## 출력 형식
5. **Report the result**
- Report the created file path.
- Report the routing file and added entry.
- Report excluded scope only when it prevents a likely misunderstanding.
```
## Validation
- [ ] `agent-ops/skills/{common|project|private}/<skill-name>/SKILL.md` exists.
- [ ] The skill contains the required purpose, invocation cases, procedure, and validation sections.
- [ ] Inputs, preflight, output format, and prohibitions are present only when they define a necessary contract.
- [ ] Every instruction is necessary for execution or verdict determination; no speculative contract remains.
- [ ] Instructions are concise, single-meaning, and free of discretionary wording without decision criteria.
- [ ] The frontmatter description and Markdown instructions are in English, except exact literals that must retain another language.
- [ ] No template authoring comment or unfilled placeholder remains.
- [ ] The frontmatter has a valid `name` and `description`.
- [ ] An agent-ops skill follows local frontmatter conventions, while a Codex-installed skill follows the system `skill-creator` frontmatter rules.
- [ ] A private override takes precedence over its same-name project skill, and only an unmatched private skill is routed from the private rule.
- If validation fails, report the missing or conflicting item and change only that item.
## Output format
```markdown
## 생성 완료
- SKILL 경로: agent-ops/skills/{common|project|private}/<skill-name>/SKILL.md
- 라우팅 추가: <대상 파일><라우팅 ><skill-name>
## 주의사항 (해당 시)
- < skill이 다루지 않는 범위 또는 주의할 >
```
## 실행 결과 검증
## Prohibitions
- [ ] `agent-ops/skills/{common|project|private}/<skill-name>/SKILL.md` 파일이 생성되었는가
- [ ] 생성된 파일이 `skill-template.md`의 필수 섹션(목적, 언제 호출할지, 실행 절차, 실행 결과 검증, 출력 형식, 금지 사항)을 포함하는가
- [ ] frontmatter에 name, description이 올바르게 기재되었는가
- [ ] agent-ops 내부 스킬이면 version 등 로컬 관례를 따르고, Codex 설치형 스킬이면 시스템 `skill-creator` frontmatter 규칙을 따르는가
- [ ] private override는 동일 이름의 project skill보다 우선되고, 짝이 없는 private skill만 private rule에 라우팅되었는가
- 검증 실패 시: 누락된 섹션 또는 라우팅 항목을 사용자에게 알리고 해당 부분만 보완한다
## 금지 사항
- private skill 또는 private rule의 내용을 tracked common·project 경로에 복사하지 않는다
- 이미 존재하는 skill 을 덮어쓰지 않는다
- 프로젝트 특화 경로(예: `app/screens/`)를 skill 본문에 하드코딩하지 않는다
- skill 생성과 무관한 코드 파일을 수정하지 않는다
- 라우팅 대상 파일의 기존 항목을 삭제하거나 재정렬하지 않는다
- 하나의 skill 에 여러 독립적인 책임을 묶지 않는다
- Do not copy private skill or private rule content into tracked common or project paths.
- Do not overwrite an existing skill.
- Do not hardcode project-specific paths such as `app/screens/` in a reusable skill.
- Do not modify code unrelated to skill creation.
- Do not delete or reorder existing routing entries.
- Do not combine multiple independent responsibilities in one skill.
- Do not add contracts for hypothetical future requirements.
- Do not use ambiguous instructions without executable decision criteria.

View file

@ -46,7 +46,7 @@ description: agent-test 환경 rules.md와 도메인/검증 시나리오별 테
- [ ] `agent-ops/skills/common/router.md``create-test` 라우팅이 있는지 확인한다.
- [ ] `agent-ops/rules/project/rules.md`가 있으면 도메인 매핑 테이블을 확인한다.
- [ ] `agent-ops/rules/project/domain/` 하위 domain rule 목록을 확인한다.
- [ ] 테스트 명령 확인을 위해 프로젝트의 대표 설정 파일을 가볍게 확인한다. 예: `package.json`, `Makefile`, `pyproject.toml`, `go.mod`, `Cargo.toml`, `docker-compose*.yml`, `.github/workflows/**`.
- [ ] 테스트 명령 확인을 위해 프로젝트의 대표 manifest, build 설정, container 설정, CI workflow를 가볍게 확인한다.
- [ ] `agent-ops/rules/common/_templates/test-env-rules-template.md`를 읽는다.
- [ ] `agent-ops/rules/common/_templates/test-case-rule-template.md`를 읽는다.
- [ ] 프로젝트에 `agent-test/_templates/env-rules-template.md` 또는 `agent-test/_templates/test-profile-template.md`가 있으면 해당 프로젝트 템플릿을 공통 템플릿보다 우선한다.

View file

@ -7,7 +7,7 @@ description: PLAN/CODE_REVIEW 작성 직전 완성된 build packet을 한 번
## 목표
완성된 in-memory PLAN 하나를 한 번 평가해 build/review route를 확정한다. routing 전용 문서나 증거 탐색을 만들지 않는다. Build의 기본값은 local이며 아래 표의 cloud 조건에 일치할 때만 승격한다. 공식 review는 항상 cloud의 Codex `gpt-5.6-sol` xhigh다. 이 스킬은 task 파일을 수정하지 않는다.
완성된 in-memory PLAN 하나를 한 번 평가해 build/review route를 확정한다. routing 전용 문서나 증거 탐색을 만들지 않는다. Build의 기본값은 local이며 아래 표의 cloud 조건에 일치할 때만 승격한다. 공식 review는 항상 cloud lane을 사용하되 agent와 model은 런타임 실행 카탈로그가 결정한다. 이 스킬은 task 파일을 수정하지 않는다.
## 입력
@ -129,9 +129,9 @@ finalizer 출력만 사용한다. lane, grade, boundary, filename을 수작업
항상 `status`, `evaluation_mode`, `missing_evidence`, `blocked_reason`을 반환한다. `status=routed`이면 다음 필드를 모두 반환한다.
- 공통: `finalizer=finalize-task-policy.sh`, `finalizer_mode=pair`
- target별: `closures`, `closure_basis`, `capability_gap`, `grade_scores`, `route_basis`, `lane`, `grade`, `filename`
- target별: `closures`, `closure_basis`, `capability_gap`, `grade_scores`, `route_basis`, `lane`, `grade`, `filename`, `catalog_route`
- build 전용: `base_route_basis`, `large_indivisible_context`, `matched_loop_risk_signatures`, `loop_risk_count`, `review_rework_count`, `evidence_integrity_failure`, `risk_boundary_matched`, `recovery_boundary_matched`
- review 전용: `route_basis=official-review`, `adapter=codex`, `model=gpt-5.6-sol`, `reasoning_effort=xhigh`
- review 전용: `route_basis=official-review`, `catalog_route=review/cloud/GNN`. 구체적인 agent와 model은 이 출력에 포함하지 않는다.
## 완료 확인

View file

@ -98,9 +98,6 @@ finalize_review() {
REVIEW_LANE=$(field "$route" lane)
REVIEW_GRADE=$(field "$route" grade)
REVIEW_FILENAME=$(field "$route" filename)
REVIEW_ADAPTER=codex
REVIEW_MODEL=gpt-5.6-sol
REVIEW_REASONING_EFFORT=xhigh
}
emit_build() {
@ -115,6 +112,7 @@ emit_build() {
printf 'build_lane=%s\n' "$BUILD_LANE"
printf 'build_grade=%s\n' "$BUILD_GRADE"
printf 'build_filename=%s\n' "$BUILD_FILENAME"
printf 'build_catalog_route=worker/%s/%s\n' "$BUILD_LANE" "$BUILD_GRADE"
}
emit_review() {
@ -122,9 +120,7 @@ emit_review() {
printf 'review_lane=%s\n' "$REVIEW_LANE"
printf 'review_grade=%s\n' "$REVIEW_GRADE"
printf 'review_filename=%s\n' "$REVIEW_FILENAME"
printf 'review_adapter=%s\n' "$REVIEW_ADAPTER"
printf 'review_model=%s\n' "$REVIEW_MODEL"
printf 'review_reasoning_effort=%s\n' "$REVIEW_REASONING_EFFORT"
printf 'review_catalog_route=review/%s/%s\n' "$REVIEW_LANE" "$REVIEW_GRADE"
}
mode=${1:-}

View file

@ -102,7 +102,7 @@ class FinalizeTaskRoutingTests(unittest.TestCase):
COMMON_SKILLS_DIR / "complete-milestone",
COMMON_SKILLS_DIR / "plan",
COMMON_SKILLS_DIR / "code-review",
COMMON_SKILLS_DIR / "refine-local-plans",
COMMON_SKILLS_DIR / "refine-plans",
COMMON_SKILLS_DIR / "finalize-task-routing",
)
contract_files: list[Path] = []
@ -129,6 +129,45 @@ class FinalizeTaskRoutingTests(unittest.TestCase):
with self.subTest(path=path, needle=needle):
self.assertNotIn(needle, text)
def test_plan_refinement_contract_is_lane_neutral(self) -> None:
refine_skill = (COMMON_SKILLS_DIR / "refine-plans" / "SKILL.md").read_text(
encoding="utf-8"
)
router = (COMMON_SKILLS_DIR / "router.md").read_text(encoding="utf-8")
plan_skill = PLAN_SKILL.read_text(encoding="utf-8")
sync_ui_skill = (COMMON_SKILLS_DIR / "sync-agent-ui" / "SKILL.md").read_text(
encoding="utf-8"
)
self.assertFalse(
(COMMON_SKILLS_DIR / "refine-local-plans" / "SKILL.md").exists()
)
self.assertIn("`PLAN-*-G??.md`", refine_skill)
self.assertIn("Build lane은 대상 자격에 사용하지 않는다.", refine_skill)
self.assertIn("`evaluation_mode=isolated-reassessment`", refine_skill)
self.assertIn("parent 값을 복사하지 않는다", refine_skill)
self.assertIn("미착수 pair의 분할", router)
self.assertIn("eligible unstarted siblings", plan_skill)
self.assertIn("strict-subset children", plan_skill)
self.assertIn("must not retain the parent route", plan_skill)
local_only_contracts = (
"refine-local-plans",
"미착수 local pair",
"`PLAN-local-G??.md`",
"Strict-subset local refinement",
"cloud pair 분리",
"eligible unstarted local siblings",
"strict-subset local children",
"기존 build/review lane, G, canonical basename",
)
for needle in local_only_contracts:
with self.subTest(needle=needle):
self.assertNotIn(needle, refine_skill)
self.assertNotIn(needle, router)
self.assertNotIn(needle, plan_skill)
self.assertNotIn(needle, sync_ui_skill)
def test_request_to_worker_contract_is_ordered_and_consistent(self) -> None:
routing_skill = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
plan_skill = PLAN_SKILL.read_text(encoding="utf-8")
@ -202,7 +241,7 @@ class FinalizeTaskRoutingTests(unittest.TestCase):
self.assertEqual(result["finalizer_mode"], "pair")
self.assert_route(result, "build", basis, lane, grade)
def test_official_review_keeps_grade_and_fixes_execution_target(self) -> None:
def test_official_review_keeps_grade_without_fixing_execution_target(self) -> None:
for grade in range(1, 11):
with self.subTest(grade=grade):
result = fields(
@ -211,9 +250,11 @@ class FinalizeTaskRoutingTests(unittest.TestCase):
self.assert_route(
result, "review", "official-review", "cloud", grade
)
self.assertEqual(result["review_adapter"], "codex")
self.assertEqual(result["review_model"], "gpt-5.6-sol")
self.assertEqual(result["review_reasoning_effort"], "xhigh")
self.assertEqual(
result["review_catalog_route"], f"review/cloud/G{grade:02d}"
)
self.assertNotIn("review_adapter", result)
self.assertNotIn("review_model", result)
def test_low_grade_cloud_requires_capability_gap_basis(self) -> None:
rejected = run(

View file

@ -264,6 +264,7 @@ common/rules.md와 내용이 중복되지 않도록 한다.
- [ ] `.gitignore``agent-test/local/``agent-test/runs/`가 추가되어 있는가
- [ ] `.geminiignore`, `.aiexclude`, `.cursorignore`, `.clineignore`에 Agent-Ops 관리 block이 있고 그 안에 `agent-task/archive/**``agent-roadmap/archive/**`가 포함되어 있는가
- [ ] `.claude/settings.json`, `opencode.json``agent-task/archive/**` 또는 `agent-roadmap/archive/**` hard read/glob deny가 남아 있지 않은가
- [ ] `rules/common``skills/common``__pycache__`, tool cache, `*.pyc`, `*.pyo`가 없고 초기화 복사에서도 제외됐는가
- [ ] 기존 archive hard deny가 있으면 init-agent-ops 표준에 맞게 제거했는가
- [ ] `.gitignore``agent-task/archive/**` 또는 `agent-roadmap/archive/**` ignore 항목을 추가하지 않았는가
- 검증 실패 시: 누락된 파일/항목을 사용자에게 알리고 해당 부분만 보완한다

View file

@ -0,0 +1,142 @@
---
name: orchestrate-agent-task-loop
description: Execute dependency-ready PLAN and CODE_REVIEW task loops with workspace write claims, a runtime-injected agent/model catalog, deterministic target failover, and persistent recovery state.
---
# Orchestrate Agent Task Loop
## Final-channel gate
Do not end the caller turn through `final` until either:
- every in-scope task has a verified archived `complete.log`, every generated work log is archived, no task or execution remains active, and the dispatcher exits `0`; or
- the user explicitly asks to stop the current run.
Use `commentary` for non-terminal status, blockers, questions, recovery notices, and partial completion. If the user asked for a persistent run, successful completion alone does not release this gate.
## Purpose
Monitor the file-backed workflow under `agent-task/` and converge ready PLAN implementation, optional self-check, official review, follow-up PLAN, and archive completion. The dispatcher owns deterministic scheduling, recovery, target transitions, and runtime evidence. Child agents own implementation and review judgments within their assigned artifact.
## Inputs
- `workspace`: trusted repository root containing `agent-task/`; defaults to the current directory.
- `execution_catalog`: required runtime agent/model catalog path, supplied with `--execution-catalog` or `AGENT_TASK_EXECUTION_CATALOG`.
- `task_group`: optional `agent-task/<task_group>` scope.
- `dry_run`: inspect routes, dependencies, claims, and catalog validity without launching an agent.
- `max_parallel`: workspace-wide active task-stage limit; defaults to `3`; `0` means unlimited.
- `retry_blocked`: retry eligible blocked tasks without changing their catalog route history.
`--validate-plan` validates one PLAN without launching orchestration and therefore does not require an execution catalog.
## Preconditions
- Read the current plan and code-review contracts routed by `agent-ops/skills/common/router.md`.
- Obtain the execution catalog from the runtime or project layer. Common owns no default agent, model, provider, or route catalog.
- Run `--dry-run` before the first live execution.
- Never bypass the physical-workspace dispatcher lock.
- Keep automatic approval inside the current workspace and the PLAN's declared write set.
## Runtime catalog contract
The catalog root contains exactly `schema_version`, `targets`, and `routes`. It must cover `worker` and `review`, and each stage must define every `local-G01` through `local-G10` and `cloud-G01` through `cloud-G10` route.
Each target has:
- an opaque `agent` identity;
- an opaque `model` identity;
- `execution_class`: `local_model` or `cloud_model`;
- optional `selfcheck_required` boolean;
- `runtime.command`: a non-empty argv template executed without a shell;
- optional `runtime.resume_command`, `preflight_command`, `environment`, `session_path`, `native_session_monitor`, and `auxiliary_logs`;
- optional `runtime.output_format`: `text` or `jsonl`.
Command templates may use only `{agent}`, `{model}`, `{target_id}`, `{workspace}`, `{attempt_dir}`, `{session_id}`, `{resume_session}`, and `{prompt}`. The catalog must not embed repository secrets; environment values should refer only to runtime-provided non-secret configuration.
Each route owns its ordered `candidates` plus optional `rule_id`, `policy_priority`, and `reason_codes`. A route may use catalog-owned `windows` instead of a fixed candidate list; every window supplies an IANA timezone, start/end time, and candidates. Exactly one window must match.
Before work starts, the dispatcher:
1. loads and validates the entire catalog;
2. verifies exact route coverage and every target reference;
3. verifies each target command is executable;
4. runs an optional target `preflight_command` for live execution;
5. records the catalog source and SHA-256 revision in the decision.
A persisted decision is valid only while the injected catalog revision and selected target snapshot still match. Catalog changes fail closed instead of silently changing an active work unit.
## Selection and failover
- Initial execution selects the first candidate in the injected route.
- Resume pins the persisted target and route revision.
- The dispatcher never queries quota before admission and never accepts a quota snapshot as selector input.
- Classify actual terminal output after an attempt. `provider-quota`, `context-limit`, `model-unavailable`, `provider-stream-disconnect`, and `provider-connection` may advance to the next unused route candidate.
- In particular, a confirmed quota/rate-limit error advances directly to the next candidate. A plain mention of quota in source text, model prose, or non-terminal output is not sufficient evidence.
- `generic-error`, process termination, work-log failure, and review-control failure do not imply quota and do not change the selected target.
- Never use a hidden promotion table or provider-specific fallback. If no next catalog candidate exists, keep recovery within the stage budget or block the task with evidence.
- Transfer logical context using the prior locator, normalized output, raw stream, workspace, and PLAN. Use native resume only when both targets opt into the same catalog-declared native-session mechanism and the session belongs to the current workspace.
## Scheduling and write claims
- Admit every dependency-ready task whose canonical PLAN write set does not collide with another active claim.
- Require exactly one non-empty `Modified Files Summary` or supported legacy heading. Reject broad, malformed, directory, outside-workspace, or missing paths.
- Atomically claim canonical paths before worker, self-check, or review execution. Keep a task's claim across retries and follow-up PLANs; release it only after verified archive completion.
- Treat explicit predecessors as unfinished while matching live execution evidence exists, even if a `complete.log` is already visible.
- Apply `max_parallel` across the physical workspace, independent of task-group filtering. Do not count internal helper coroutines as agent slots.
- A blocker delays only that task and its dependency closure. Continue draining independent work.
## Prompt and child boundary
Prefix worker and review prompts with the dispatcher-child boundary that prohibits starting or monitoring another orchestration loop. A child may use `dispatch.py --validate-plan` only when its plan or review finalization requires it.
Prompts must include absolute artifact paths and instruct the child to follow the repository's language and output rules. Do not hardcode a programming language, human language, agent, model, or provider in common prompts.
Never ask a child to create, edit, or summarize `WORK_LOG.md`; that file is dispatcher-owned.
## Self-check
Run self-check only when the selected catalog target declares `selfcheck_required=true`. The completing decision, not a fixed agent identity or execution class, determines the requirement.
Accept self-check completion only when `## Implementation Checklist` or its supported legacy heading contains at least one checkbox and every checkbox has a non-empty value. Run one full pass, then resume the latest successful native context for at most 10 unchecked-item retries when the target supports native resume. Block instead of silently starting a new context when a required persisted context is unavailable.
## Runtime evidence and recovery
- Store each attempt under the dispatcher state directory with `locator.json`, `stream.log`, `normalized-output.log`, and `heartbeat.log`.
- Record the target id, opaque agent/model identity, execution class, runtime contract, catalog evidence, process identity, workspace identity, timestamps, result, and exact failure evidence.
- Treat stderr as terminal diagnostic evidence. For JSONL, recognize generic terminal event fields such as error/fatal type or severity, rejected/failed status with an error code, and explicit error flags.
- Determine liveness from PID/start-token/process-marker evidence and actual stream or native-session progress. Heartbeat mtime is never agent progress.
- Never start a duplicate attempt while owned live evidence remains.
- Keep a 10-consecutive-failure budget per task stage. Reset only that stage's budget after success.
- Preserve failed attempt logs. Delete successful attempt logs only after verified archive completion and no live evidence.
## Work log
- Keep one dispatcher-owned `WORK_LOG.md` per task group.
- Append chronological `START` and `FINISH` rows with UTC time, task artifact, plan loop, role, attempt, selected agent/model display, result, and locator.
- Archive the group log as the next `work_log_N.log` only after every observed task in the group is verified complete and idle.
- Work-log write or archive failure is a retryable control-plane failure and prevents exit `0`.
## Invocation
```bash
python3 agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py \
--workspace /absolute/repository \
--execution-catalog /runtime/config/execution-catalog.json \
--dry-run
```
Remove `--dry-run` to start execution. Add `--task-group <name>`, `--max-parallel <n>`, or `--retry-blocked` only when requested by the workflow.
Launch the live dispatcher as one persistent foreground process. Do not wrap it in an arbitrary timeout and do not start a second dispatcher after a normal tool yield. Wait on the same execution handle until an attention event or terminal exit.
## Completion checklist
- [ ] Catalog was injected, fully validated, preflighted, and revision-pinned.
- [ ] No fixed common agent/model/provider route or quota probe was used.
- [ ] Runtime quota errors moved only to the next catalog candidate.
- [ ] Dependencies, write claims, and workspace concurrency were enforced.
- [ ] Required self-check and official review stages completed.
- [ ] Every observed task has a verified archived `complete.log`.
- [ ] Work logs and successful attempt cleanup were reconciled.
- [ ] No active, waiting, pending, or blocked in-scope task remains.
- [ ] Dispatcher exited `0` before successful final response.

View file

@ -0,0 +1,4 @@
interface:
display_name: "Agent Task Loop Orchestrator"
short_description: "Orchestrate PLAN and review loops with an injected runtime catalog"
default_prompt: "Use $orchestrate-agent-task-loop to execute the active agent-task workflow."

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""Observation output emitter and formatting utilities for agent-task dispatcher."""
from __future__ import annotations
SEP = "-" * 42
def banner(event: str, task: str, lines: list[str] | None = None) -> None:
display_task = task.rsplit("/", 1)[-1]
print(SEP, flush=True)
print(f"{event}: {display_task}", flush=True)
print(SEP, flush=True)
if display_task != task:
print(f"task={task}", flush=True)
for line in lines or []:
print(line, flush=True)
def attempt_event(prefix: str, message: str) -> None:
print(f"{prefix} {message}", flush=True)
def validation_claim(path: str) -> None:
"""Emit one canonical write claim for standalone PLAN validation."""
print(path, flush=True)

View file

@ -0,0 +1,379 @@
#!/usr/bin/env python3
"""Runtime-injected execution-target catalog and route policy.
This common module intentionally owns no agent or model catalog. A caller
supplies a JSON catalog at runtime; this module validates it and resolves one
ordered route without interpreting provider-specific identities.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from datetime import datetime, time
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
CATALOG_SCHEMA_VERSION = "1.0"
VALID_STAGES = {"worker", "review"}
VALID_LANES = {"local", "cloud"}
VALID_EXECUTION_CLASSES = {"local_model", "cloud_model"}
VALID_OUTPUT_FORMATS = {"jsonl", "text"}
ALLOWED_TEMPLATE_FIELDS = {
"agent",
"attempt_dir",
"model",
"prompt",
"resume_session",
"session_id",
"target_id",
"workspace",
}
class CatalogError(ValueError):
"""The injected execution catalog is missing or malformed."""
@dataclass(frozen=True)
class RouteTarget:
catalog_id: str
agent: str
model: str
execution_class: str
selfcheck_required: bool
runtime: dict[str, Any]
@dataclass(frozen=True)
class ExecutionTargetCatalog:
source: Path
revision: str
targets: dict[str, RouteTarget]
routes: dict[str, dict[str, dict[str, Any]]]
@dataclass(frozen=True)
class PolicyDecision:
route_id: str
rule_id: str
policy_priority: int
reason_codes: tuple[str, ...]
time_window: str
catalog_revision: str
candidates: tuple[RouteTarget, ...]
def _require_string(value: object, label: str) -> str:
if not isinstance(value, str) or not value:
raise CatalogError(f"{label} must be a non-empty string")
return value
def _validate_template(parts: object, label: str) -> tuple[str, ...]:
if not isinstance(parts, list) or not parts:
raise CatalogError(f"{label} must be a non-empty string list")
if not all(isinstance(part, str) and part for part in parts):
raise CatalogError(f"{label} must contain only non-empty strings")
for part in parts:
offset = 0
while True:
start = part.find("{", offset)
if start < 0:
break
end = part.find("}", start + 1)
if end < 0:
raise CatalogError(f"{label} contains an unmatched '{{': {part!r}")
field = part[start + 1 : end]
if field not in ALLOWED_TEMPLATE_FIELDS:
raise CatalogError(
f"{label} uses unsupported template field {field!r}"
)
offset = end + 1
return tuple(parts)
def _validate_runtime(value: object, label: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise CatalogError(f"{label} must be an object")
unknown = set(value) - {
"command",
"resume_command",
"preflight_command",
"environment",
"output_format",
"session_path",
"native_session_monitor",
"auxiliary_logs",
}
if unknown:
raise CatalogError(f"{label} has unsupported keys: {sorted(unknown)}")
command = list(_validate_template(value.get("command"), f"{label}.command"))
if "{" in command[0] or "}" in command[0]:
raise CatalogError(f"{label}.command executable must be a literal path or name")
runtime: dict[str, Any] = {
"command": command,
"output_format": value.get("output_format", "text"),
}
if runtime["output_format"] not in VALID_OUTPUT_FORMATS:
raise CatalogError(
f"{label}.output_format must be one of {sorted(VALID_OUTPUT_FORMATS)}"
)
for field in ("resume_command", "preflight_command"):
if field in value:
template = list(
_validate_template(value[field], f"{label}.{field}")
)
if "{" in template[0] or "}" in template[0]:
raise CatalogError(
f"{label}.{field} executable must be a literal path or name"
)
runtime[field] = template
environment = value.get("environment", {})
if not isinstance(environment, dict) or not all(
isinstance(key, str)
and key
and isinstance(item, str)
for key, item in environment.items()
):
raise CatalogError(f"{label}.environment must be a string map")
runtime["environment"] = dict(environment)
session_path = value.get("session_path")
if session_path is not None:
runtime["session_path"] = _require_string(
session_path, f"{label}.session_path"
)
_validate_template([session_path], f"{label}.session_path")
monitor = value.get("native_session_monitor", False)
if not isinstance(monitor, bool):
raise CatalogError(f"{label}.native_session_monitor must be a boolean")
runtime["native_session_monitor"] = monitor
auxiliary_logs = value.get("auxiliary_logs", [])
if not isinstance(auxiliary_logs, list) or not all(
isinstance(item, str) and item for item in auxiliary_logs
):
raise CatalogError(f"{label}.auxiliary_logs must be a string list")
for index, item in enumerate(auxiliary_logs):
_validate_template([item], f"{label}.auxiliary_logs[{index}]")
runtime["auxiliary_logs"] = list(auxiliary_logs)
return runtime
def _validate_target(target_id: str, value: object) -> RouteTarget:
label = f"targets.{target_id}"
if not isinstance(value, dict):
raise CatalogError(f"{label} must be an object")
unknown = set(value) - {
"agent",
"model",
"execution_class",
"selfcheck_required",
"runtime",
}
if unknown:
raise CatalogError(f"{label} has unsupported keys: {sorted(unknown)}")
execution_class = value.get("execution_class")
if execution_class not in VALID_EXECUTION_CLASSES:
raise CatalogError(
f"{label}.execution_class must be one of "
f"{sorted(VALID_EXECUTION_CLASSES)}"
)
selfcheck_required = value.get("selfcheck_required", False)
if not isinstance(selfcheck_required, bool):
raise CatalogError(f"{label}.selfcheck_required must be a boolean")
return RouteTarget(
catalog_id=target_id,
agent=_require_string(value.get("agent"), f"{label}.agent"),
model=_require_string(value.get("model"), f"{label}.model"),
execution_class=execution_class,
selfcheck_required=selfcheck_required,
runtime=_validate_runtime(value.get("runtime"), f"{label}.runtime"),
)
def _validate_window(value: object, label: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise CatalogError(f"{label} must be an object")
required = {"timezone", "start", "end", "candidates"}
missing = required - set(value)
if missing:
raise CatalogError(f"{label} missing keys: {sorted(missing)}")
timezone_name = _require_string(value["timezone"], f"{label}.timezone")
try:
ZoneInfo(timezone_name)
except ZoneInfoNotFoundError as exc:
raise CatalogError(f"{label}.timezone is unknown: {timezone_name}") from exc
for field in ("start", "end"):
raw = _require_string(value[field], f"{label}.{field}")
try:
time.fromisoformat(raw)
except ValueError as exc:
raise CatalogError(f"{label}.{field} must be HH:MM[:SS]") from exc
return dict(value)
def _validate_route(
value: object,
label: str,
target_ids: set[str],
) -> dict[str, Any]:
if not isinstance(value, dict):
raise CatalogError(f"{label} must be an object")
unknown = set(value) - {
"candidates",
"rule_id",
"policy_priority",
"reason_codes",
"windows",
}
if unknown:
raise CatalogError(f"{label} has unsupported keys: {sorted(unknown)}")
candidates = value.get("candidates")
windows = value.get("windows")
if (candidates is None) == (windows is None):
raise CatalogError(
f"{label} must define exactly one of candidates or windows"
)
normalized = dict(value)
if windows is not None:
if not isinstance(windows, list) or not windows:
raise CatalogError(f"{label}.windows must be a non-empty list")
normalized["windows"] = [
_validate_window(item, f"{label}.windows[{index}]")
for index, item in enumerate(windows)
]
candidate_lists = [item["candidates"] for item in normalized["windows"]]
else:
candidate_lists = [candidates]
for index, candidate_list in enumerate(candidate_lists):
item_label = f"{label}.candidates[{index}]"
if not isinstance(candidate_list, list) or not candidate_list:
raise CatalogError(f"{item_label} must be a non-empty list")
if len(candidate_list) != len(set(candidate_list)):
raise CatalogError(f"{item_label} must not contain duplicates")
unknown_targets = [item for item in candidate_list if item not in target_ids]
if unknown_targets:
raise CatalogError(
f"{item_label} references unknown targets: {unknown_targets}"
)
priority = value.get("policy_priority", 0)
if isinstance(priority, bool) or not isinstance(priority, int):
raise CatalogError(f"{label}.policy_priority must be an integer")
reasons = value.get("reason_codes", [])
if not isinstance(reasons, list) or not all(
isinstance(item, str) and item for item in reasons
):
raise CatalogError(f"{label}.reason_codes must be a string list")
return normalized
def load_catalog(path: str | Path) -> ExecutionTargetCatalog:
source = Path(path).expanduser().resolve()
try:
raw = source.read_bytes()
except OSError as exc:
raise CatalogError(f"execution catalog is unreadable: {source}: {exc}") from exc
try:
value = json.loads(raw)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise CatalogError(f"execution catalog is not valid UTF-8 JSON: {source}") from exc
if not isinstance(value, dict):
raise CatalogError("execution catalog root must be an object")
if set(value) != {"schema_version", "targets", "routes"}:
raise CatalogError(
"execution catalog root must contain exactly schema_version, targets, routes"
)
if value["schema_version"] != CATALOG_SCHEMA_VERSION:
raise CatalogError(
f"execution catalog schema_version must be {CATALOG_SCHEMA_VERSION!r}"
)
raw_targets = value["targets"]
if not isinstance(raw_targets, dict) or not raw_targets:
raise CatalogError("execution catalog targets must be a non-empty object")
targets = {
_require_string(target_id, "target id"): _validate_target(target_id, item)
for target_id, item in raw_targets.items()
}
raw_routes = value["routes"]
if not isinstance(raw_routes, dict) or set(raw_routes) != VALID_STAGES:
raise CatalogError(
f"execution catalog routes must contain exactly {sorted(VALID_STAGES)}"
)
routes: dict[str, dict[str, dict[str, Any]]] = {}
required_route_ids = {
f"{lane}-G{grade:02d}"
for lane in VALID_LANES
for grade in range(1, 11)
}
for stage in sorted(VALID_STAGES):
stage_routes = raw_routes[stage]
if not isinstance(stage_routes, dict) or set(stage_routes) != required_route_ids:
missing = sorted(required_route_ids - set(stage_routes or {}))
extra = sorted(set(stage_routes or {}) - required_route_ids)
raise CatalogError(
f"routes.{stage} must cover local/cloud G01..G10 exactly; "
f"missing={missing}, extra={extra}"
)
routes[stage] = {
route_id: _validate_route(
route, f"routes.{stage}.{route_id}", set(targets)
)
for route_id, route in stage_routes.items()
}
revision = hashlib.sha256(raw).hexdigest()
return ExecutionTargetCatalog(source, revision, targets, routes)
def canonical_target(catalog: ExecutionTargetCatalog, target_id: str) -> RouteTarget | None:
return catalog.targets.get(target_id)
def _window_matches(window: dict[str, Any], evaluated_at: datetime) -> bool:
local_time = evaluated_at.astimezone(ZoneInfo(window["timezone"])).time()
start = time.fromisoformat(window["start"])
end = time.fromisoformat(window["end"])
return start <= local_time < end if start < end else local_time >= start or local_time < end
def select_policy(
*,
catalog: ExecutionTargetCatalog,
stage: str,
lane: str,
grade: int,
evaluated_at: datetime,
) -> PolicyDecision:
if stage not in VALID_STAGES:
raise ValueError(f"unsupported stage: {stage}")
if lane not in VALID_LANES:
raise ValueError(f"unsupported lane: {lane}")
if not 1 <= grade <= 10:
raise ValueError(f"grade must be in G01..G10: {grade}")
if evaluated_at.tzinfo is None or evaluated_at.utcoffset() is None:
raise ValueError("evaluated_at must be timezone-aware")
route_id = f"{lane}-G{grade:02d}"
route = catalog.routes[stage][route_id]
selected_route = route
time_window = "not_applicable"
if "windows" in route:
matches = [item for item in route["windows"] if _window_matches(item, evaluated_at)]
if len(matches) != 1:
raise CatalogError(
f"routes.{stage}.{route_id}.windows must match exactly once; matches={len(matches)}"
)
selected_route = {**route, **matches[0]}
time_window = (
f"{matches[0]['timezone']}:{matches[0]['start']}-{matches[0]['end']}"
)
candidate_ids = selected_route["candidates"]
return PolicyDecision(
route_id=route_id,
rule_id=str(selected_route.get("rule_id") or f"{stage}-{route_id}"),
policy_priority=int(selected_route.get("policy_priority", 0)),
reason_codes=tuple(selected_route.get("reason_codes", [])),
time_window=time_window,
catalog_revision=catalog.revision,
candidates=tuple(catalog.targets[target_id] for target_id in candidate_ids),
)

View file

@ -0,0 +1,501 @@
#!/usr/bin/env python3
"""Select an execution target from a runtime-injected catalog.
The selector performs no quota lookup. Every initial candidate is eligible;
runtime failures such as ``provider-quota`` advance to the next catalog entry.
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
SCHEMA_VERSION = "2.0"
CATALOG_ENV = "AGENT_TASK_EXECUTION_CATALOG"
TIMEZONE_NAME = "UTC"
_FILENAME_RE = re.compile(r"^(PLAN|CODE_REVIEW)-(local|cloud)-G(\d{2})\.md$")
_MILESTONE_TASK_ID_PATTERN = r"[A-Za-z0-9]+(?:[-_+=][A-Za-z0-9]+){0,3}"
_MILESTONE_TASK_ID_RE = re.compile(rf"\A{_MILESTONE_TASK_ID_PATTERN}\Z")
_HEADER_RE = re.compile(
r"\A<!--\s*task=(?P<task>\S+)\s+plan=(?P<plan>\d+)\s+tag=(?P<tag>\S+)"
r"(?:\s+milestone-task=(?P<milestone_task>[^,\s]+(?:,[^,\s]+)*))?"
r"\s*-->[ \t]*(?:\r?\n|\Z)"
)
_STAGE_BY_KIND = {"PLAN": "worker", "CODE_REVIEW": "review"}
_VALID_TRANSITIONS = {"initial", "resume", "failover"}
_QUALIFIED_FAILOVER_FAILURES = {
"provider-quota",
"context-limit",
"model-unavailable",
"provider-stream-disconnect",
"provider-connection",
}
def _load_policy():
path = Path(__file__).resolve().parent / "execution_target_policy.py"
spec = importlib.util.spec_from_file_location("execution_target_policy", path)
if spec is None or spec.loader is None:
raise RuntimeError(f"failed to load execution target policy: {path}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
policy = _load_policy()
class SelectorInputError(Exception):
"""Input contract violation returned as stderr JSON with a non-zero exit."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
def resolve_catalog_path(value: str | Path | None = None) -> Path:
raw = str(value) if value is not None else os.environ.get(CATALOG_ENV, "")
if not raw:
raise SelectorInputError(
"missing_execution_catalog",
f"inject the execution catalog with --catalog or {CATALOG_ENV}",
)
return Path(raw).expanduser().resolve()
def load_runtime_catalog(value: str | Path | None = None):
try:
return policy.load_catalog(resolve_catalog_path(value))
except SelectorInputError:
raise
except (OSError, ValueError) as exc:
raise SelectorInputError("invalid_execution_catalog", str(exc)) from exc
def _parse_filename(task_file: Path) -> tuple[str, str, int]:
name = Path(task_file).name
match = _FILENAME_RE.match(name)
if match is None:
raise SelectorInputError(
"invalid_task_filename",
f"task file must match (PLAN|CODE_REVIEW)-(local|cloud)-GNN.md: {name!r}",
)
kind, lane, grade_str = match.group(1), match.group(2), match.group(3)
grade = int(grade_str)
if not 1 <= grade <= 10:
raise SelectorInputError("invalid_grade", f"grade must be G01..G10: G{grade_str}")
return kind, lane, grade
def _parse_header(task_file: Path) -> tuple[str, int, str, str | None]:
try:
with Path(task_file).open("rb") as handle:
text = handle.read(1024).decode("utf-8", errors="replace")
except OSError as exc:
raise SelectorInputError("task_file_unreadable", str(exc)) from exc
match = _HEADER_RE.search(text)
if match is None:
raise SelectorInputError(
"malformed_header",
"first line must contain <!-- task=... plan=N tag=... "
"[milestone-task=id[,id...]] -->",
)
task = match.group("task")
milestone_task = match.group("milestone_task")
task_ids = tuple(milestone_task.split(",")) if milestone_task else ()
invalid_ids = [item for item in task_ids if _MILESTONE_TASK_ID_RE.fullmatch(item) is None]
if invalid_ids:
raise SelectorInputError(
"invalid_milestone_task",
"milestone-task ids must follow the Milestone item-id grammar: "
+ ", ".join(invalid_ids),
)
if len(task_ids) != len(set(task_ids)):
raise SelectorInputError(
"duplicate_milestone_task",
"milestone-task must contain unique comma-separated Task ids",
)
milestone_group = task.split("/", 1)[0].startswith("m-")
if milestone_group and not milestone_task:
raise SelectorInputError(
"missing_milestone_task",
"m-* task headers require milestone-task=id[,id...]",
)
if not milestone_group and milestone_task:
raise SelectorInputError(
"unexpected_milestone_task",
"non-milestone task headers must omit milestone-task",
)
return task, int(match.group("plan")), match.group("tag"), milestone_task
def _work_unit_id(header: tuple[str, int, str, str | None]) -> str:
task, plan, tag, milestone_task = header
result = f"{task}::plan-{plan}::tag-{tag}"
if milestone_task:
result += f"::milestone-task-{milestone_task}"
return result
def _target_snapshot(target) -> dict:
return {
"target_id": target.catalog_id,
"agent": target.agent,
"model": target.model,
"execution_class": target.execution_class,
"selfcheck_required": target.selfcheck_required,
}
def _candidate_snapshot(target, rank: int) -> dict:
return {"candidate_rank": rank, **_target_snapshot(target)}
def _validate_target_snapshot(value: object, prefix: str) -> dict:
code = "malformed_prior_decision"
if not isinstance(value, dict):
raise SelectorInputError(code, f"{prefix} must be an object")
required = {
"target_id",
"agent",
"model",
"execution_class",
"selfcheck_required",
}
missing = required - set(value)
if missing:
raise SelectorInputError(code, f"{prefix} missing keys: {sorted(missing)}")
for field in ("target_id", "agent", "model"):
if not isinstance(value[field], str) or not value[field]:
raise SelectorInputError(code, f"{prefix}.{field} must be a non-empty string")
if value["execution_class"] not in policy.VALID_EXECUTION_CLASSES:
raise SelectorInputError(
code,
f"{prefix}.execution_class must be one of {sorted(policy.VALID_EXECUTION_CLASSES)}",
)
if not isinstance(value["selfcheck_required"], bool):
raise SelectorInputError(code, f"{prefix}.selfcheck_required must be a boolean")
return value
def _validate_prior_decision(value: object) -> dict:
code = "malformed_prior_decision"
if not isinstance(value, dict):
raise SelectorInputError(code, "prior_decision must be an object")
required = {
"schema_version",
"work_unit_id",
"stage",
"lane",
"grade",
"catalog",
"selected",
"candidates",
"decision",
"transition",
}
missing = required - set(value)
if missing:
raise SelectorInputError(code, f"prior_decision missing keys: {sorted(missing)}")
if value["schema_version"] != SCHEMA_VERSION:
raise SelectorInputError(code, f"prior_decision.schema_version must be {SCHEMA_VERSION!r}")
if value["stage"] not in policy.VALID_STAGES or value["lane"] not in policy.VALID_LANES:
raise SelectorInputError(code, "prior_decision stage/lane is invalid")
grade = value["grade"]
if isinstance(grade, bool) or not isinstance(grade, int) or not 1 <= grade <= 10:
raise SelectorInputError(code, "prior_decision.grade must be G01..G10")
if not isinstance(value["work_unit_id"], str) or not value["work_unit_id"]:
raise SelectorInputError(code, "prior_decision.work_unit_id must be a non-empty string")
catalog = value["catalog"]
if not isinstance(catalog, dict):
raise SelectorInputError(code, "prior_decision.catalog must be an object")
for field in ("schema_version", "revision", "source", "route_id"):
if not isinstance(catalog.get(field), str) or not catalog[field]:
raise SelectorInputError(code, f"prior_decision.catalog.{field} must be a non-empty string")
_validate_target_snapshot(value["selected"], "prior_decision.selected")
candidates = value["candidates"]
if not isinstance(candidates, list) or not candidates:
raise SelectorInputError(code, "prior_decision.candidates must be a non-empty list")
for index, candidate in enumerate(candidates, 1):
_validate_target_snapshot(candidate, f"prior_decision.candidates[{index - 1}]")
if candidate.get("candidate_rank") != index:
raise SelectorInputError(
code,
f"prior_decision.candidates[{index - 1}].candidate_rank must be {index}",
)
decision = value["decision"]
if not isinstance(decision, dict):
raise SelectorInputError(code, "prior_decision.decision must be an object")
for field in ("rule_id", "evaluated_at", "timezone", "time_window"):
if not isinstance(decision.get(field), str) or not decision[field]:
raise SelectorInputError(code, f"prior_decision.decision.{field} must be a non-empty string")
if not isinstance(decision.get("policy_priority"), int) or isinstance(
decision["policy_priority"], bool
):
raise SelectorInputError(code, "prior_decision.decision.policy_priority must be an integer")
if not isinstance(decision.get("reason_codes"), list) or not all(
isinstance(item, str) and item for item in decision["reason_codes"]
):
raise SelectorInputError(code, "prior_decision.decision.reason_codes must be a string list")
if not isinstance(decision.get("pinned"), bool):
raise SelectorInputError(code, "prior_decision.decision.pinned must be a boolean")
transition = value["transition"]
if not isinstance(transition, dict) or transition.get("trigger") not in _VALID_TRANSITIONS:
raise SelectorInputError(code, "prior_decision.transition is invalid")
return value
def _catalog_matches_prior(catalog, prior: dict, decision) -> None:
code = "catalog_revision_mismatch"
evidence = prior["catalog"]
if evidence["schema_version"] != policy.CATALOG_SCHEMA_VERSION:
raise SelectorInputError(code, "persisted catalog schema is unsupported")
if evidence["revision"] != catalog.revision:
raise SelectorInputError(
code,
"the injected execution catalog changed after this work unit was selected",
)
if evidence["route_id"] != decision.route_id:
raise SelectorInputError(code, "persisted catalog route does not match the task route")
def _validate_prior_candidate_identity(prior: dict, *, catalog, decision) -> None:
code = "malformed_prior_decision"
expected = [_candidate_snapshot(item, rank) for rank, item in enumerate(decision.candidates, 1)]
if prior["candidates"] != expected:
raise SelectorInputError(code, "prior_decision candidates do not match the injected catalog route")
selected = prior["selected"]
if selected not in [{key: value for key, value in item.items() if key != "candidate_rank"} for item in expected]:
raise SelectorInputError(code, "prior_decision selected target is not in the injected route")
def _base_decision(
*,
catalog,
route,
work_unit_id: str,
stage: str,
lane: str,
grade: int,
evaluated_at: datetime,
selected,
pinned: bool,
previous_target: dict | None,
trigger: str,
) -> dict:
selected_snapshot = _target_snapshot(selected)
return {
"schema_version": SCHEMA_VERSION,
"work_unit_id": work_unit_id,
"stage": stage,
"lane": lane,
"grade": grade,
"catalog": {
"schema_version": policy.CATALOG_SCHEMA_VERSION,
"revision": catalog.revision,
"source": str(catalog.source),
"route_id": route.route_id,
},
"selected": selected_snapshot,
"candidates": [
_candidate_snapshot(item, rank)
for rank, item in enumerate(route.candidates, 1)
],
"decision": {
"rule_id": route.rule_id,
"policy_priority": route.policy_priority,
"reason_codes": list(route.reason_codes),
"evaluated_at": evaluated_at.astimezone(timezone.utc).isoformat(),
"timezone": TIMEZONE_NAME,
"time_window": route.time_window,
"pinned": pinned,
},
"transition": {
"previous_target": previous_target,
"next_target": selected_snapshot,
"trigger": trigger,
"context_transfer": "logical" if trigger == "failover" else "none",
},
}
def select_execution_target_for_route(
*,
work_unit_id: str,
stage: str,
lane: str,
grade: int,
evaluated_at: datetime,
catalog_path: str | Path | None = None,
transition: str = "initial",
prior_decision: dict | None = None,
failure_class: str | None = None,
) -> dict:
if transition not in _VALID_TRANSITIONS:
raise SelectorInputError("invalid_transition", f"unsupported transition: {transition}")
if evaluated_at.tzinfo is None or evaluated_at.utcoffset() is None:
raise SelectorInputError("naive_evaluated_at", "evaluated_at must be timezone-aware")
catalog = load_runtime_catalog(catalog_path)
try:
route = policy.select_policy(
catalog=catalog,
stage=stage,
lane=lane,
grade=grade,
evaluated_at=evaluated_at,
)
except ValueError as exc:
raise SelectorInputError("invalid_route", str(exc)) from exc
if transition == "initial":
if prior_decision is not None:
raise SelectorInputError("unexpected_prior_decision", "initial transition must not include prior_decision")
return _base_decision(
catalog=catalog,
route=route,
work_unit_id=work_unit_id,
stage=stage,
lane=lane,
grade=grade,
evaluated_at=evaluated_at,
selected=route.candidates[0],
pinned=False,
previous_target=None,
trigger="initial",
)
prior = _validate_prior_decision(prior_decision)
expected_identity = (work_unit_id, stage, lane, grade)
actual_identity = (
prior["work_unit_id"],
prior["stage"],
prior["lane"],
prior["grade"],
)
if actual_identity != expected_identity:
raise SelectorInputError("prior_decision_mismatch", "prior_decision belongs to a different work unit or route")
_catalog_matches_prior(catalog, prior, route)
_validate_prior_candidate_identity(prior, catalog=catalog, decision=route)
selected_id = prior["selected"]["target_id"]
selected_index = [item.catalog_id for item in route.candidates].index(selected_id)
if transition == "resume":
selected = route.candidates[selected_index]
return _base_decision(
catalog=catalog,
route=route,
work_unit_id=work_unit_id,
stage=stage,
lane=lane,
grade=grade,
evaluated_at=evaluated_at,
selected=selected,
pinned=True,
previous_target=_target_snapshot(selected),
trigger="resume",
)
if failure_class not in _QUALIFIED_FAILOVER_FAILURES:
raise SelectorInputError(
"unqualified_failover",
f"failure_class does not qualify for target failover: {failure_class!r}",
)
next_index = selected_index + 1
if next_index >= len(route.candidates):
raise SelectorInputError("no_failover_candidate", "the injected route has no unused next target")
return _base_decision(
catalog=catalog,
route=route,
work_unit_id=work_unit_id,
stage=stage,
lane=lane,
grade=grade,
evaluated_at=evaluated_at,
selected=route.candidates[next_index],
pinned=False,
previous_target=dict(prior["selected"]),
trigger="failover",
)
def select_execution_target(
task_file: Path,
*,
stage: str | None = None,
evaluated_at: datetime | None = None,
catalog_path: str | Path | None = None,
transition: str = "initial",
prior_decision: dict | None = None,
failure_class: str | None = None,
) -> dict:
kind, lane, grade = _parse_filename(Path(task_file))
inferred_stage = _STAGE_BY_KIND[kind]
if stage is not None and stage != inferred_stage:
raise SelectorInputError(
"stage_mismatch",
f"stage {stage!r} does not match task filename stage {inferred_stage!r}",
)
return select_execution_target_for_route(
work_unit_id=_work_unit_id(_parse_header(Path(task_file))),
stage=inferred_stage,
lane=lane,
grade=grade,
evaluated_at=evaluated_at or datetime.now(timezone.utc),
catalog_path=catalog_path,
transition=transition,
prior_decision=prior_decision,
failure_class=failure_class,
)
def to_json(payload: dict) -> str:
return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def _load_json_arg(value: str | None):
if value is None:
return None
path = Path(value)
try:
return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else json.loads(value)
except (OSError, json.JSONDecodeError) as exc:
raise SelectorInputError("invalid_json_argument", str(exc)) from exc
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("task_file", type=Path)
parser.add_argument("--stage", choices=sorted(policy.VALID_STAGES))
parser.add_argument("--catalog")
parser.add_argument("--evaluated-at")
parser.add_argument("--transition", choices=sorted(_VALID_TRANSITIONS), default="initial")
parser.add_argument("--prior-decision")
parser.add_argument("--failure-class")
args = parser.parse_args(argv)
try:
evaluated_at = datetime.fromisoformat(args.evaluated_at) if args.evaluated_at else None
payload = select_execution_target(
args.task_file,
stage=args.stage,
evaluated_at=evaluated_at,
catalog_path=args.catalog,
transition=args.transition,
prior_decision=_load_json_arg(args.prior_decision),
failure_class=args.failure_class,
)
except (SelectorInputError, ValueError) as exc:
print(
to_json({"error": {"code": getattr(exc, "code", "invalid_input"), "message": str(exc)}}),
file=sys.stderr,
)
return 2
print(to_json(payload))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,330 @@
import argparse
import asyncio
import importlib.util
import json
import os
import stat
import subprocess
import sys
import unittest
from datetime import datetime, timezone
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import mock
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "dispatch.py"
SPEC = importlib.util.spec_from_file_location("agent_task_dispatch_test", SCRIPT)
dispatch = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
sys.modules[SPEC.name] = dispatch
SPEC.loader.exec_module(dispatch)
def catalog_value(command: str = "/bin/true") -> dict:
targets = {
"primary": {
"agent": "runner-primary",
"model": "model-primary",
"execution_class": "local_model",
"selfcheck_required": True,
"runtime": {
"command": [command, "--workspace", "{workspace}", "--model", "{model}", "{prompt}"],
"resume_command": [command, "--resume", "{resume_session}", "{prompt}"],
"environment": {"TARGET_ID": "{target_id}"},
"output_format": "jsonl",
"native_session_monitor": True,
"session_path": "sessions/{session_id}.jsonl",
},
},
"alternate": {
"agent": "runner-alternate",
"model": "model-alternate",
"execution_class": "cloud_model",
"runtime": {"command": [command, "{prompt}"]},
},
}
routes = {"worker": {}, "review": {}}
for stage in routes:
for lane in ("local", "cloud"):
for grade in range(1, 11):
routes[stage][f"{lane}-G{grade:02d}"] = {
"candidates": ["primary", "alternate"],
"rule_id": f"{stage}-{lane}-{grade:02d}",
"reason_codes": ["injected-route"],
}
return {"schema_version": "1.0", "targets": targets, "routes": routes}
def write_catalog(root: Path, value: dict | None = None) -> Path:
path = root / "execution-catalog.json"
path.write_text(json.dumps(value or catalog_value()), encoding="utf-8")
return path
def write_plan(root: Path, *, task_name: str = "group/01_task") -> Path:
directory = root / "agent-task" / task_name
directory.mkdir(parents=True, exist_ok=True)
path = directory / "PLAN-cloud-G05.md"
path.write_text(
f"<!-- task={task_name} plan=0 tag=API -->\n\n"
"# Plan\n\n## Modified Files Summary\n\n"
"| File | Action |\n|---|---|\n| `src/item.txt` | modify |\n",
encoding="utf-8",
)
return path
def task_from_plan(root: Path, plan: Path) -> dispatch.Task:
directory = plan.parent
return dispatch.Task(
name="group/01_task",
directory=directory,
plan=plan,
review=None,
user_review=None,
recovery=False,
index=1,
write_set={"src/item.txt"},
write_set_known=True,
plan_hash=dispatch.sha256_file(plan),
)
class RuntimeCatalogDispatcherTests(unittest.TestCase):
def setUp(self):
self.previous_catalog = dispatch.EXECUTION_CATALOG_PATH
def tearDown(self):
dispatch.EXECUTION_CATALOG_PATH = self.previous_catalog
def test_agent_spec_is_loaded_from_persisted_catalog_evidence(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
catalog = write_catalog(root)
plan = write_plan(root)
dispatch.EXECUTION_CATALOG_PATH = catalog
selector = dispatch._selector_module()
decision = selector.select_execution_target(plan, catalog_path=catalog)
spec = dispatch.agent_spec_from_decision(decision)
self.assertEqual(spec.target_id, "primary")
self.assertEqual(spec.cli, "runner-primary")
self.assertEqual(spec.model, "model-primary")
self.assertTrue(spec.native_resume)
self.assertEqual(spec.runtime["command"][0], "/bin/true")
def test_agent_spec_rejects_catalog_change_after_selection(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
catalog = write_catalog(root)
plan = write_plan(root)
selector = dispatch._selector_module()
decision = selector.select_execution_target(plan, catalog_path=catalog)
changed = catalog_value()
changed["targets"]["primary"]["model"] = "changed"
catalog.write_text(json.dumps(changed), encoding="utf-8")
with self.assertRaisesRegex(dispatch.ExecutionDecisionError, "변경"):
dispatch.agent_spec_from_decision(decision)
def test_command_is_expanded_only_from_runtime_template(self):
spec = dispatch.AgentSpec(
"opaque-agent",
"opaque-model",
"opaque-agent/opaque-model",
target_id="opaque-id",
runtime={
"command": ["runner", "{workspace}", "{model}", "{session_id}", "{attempt_dir}", "{prompt}"],
"resume_command": ["runner", "resume", "{resume_session}", "{prompt}"],
},
)
command = dispatch.build_command(
spec,
"do work",
Path("/workspace"),
"session-1",
Path("/attempt"),
)
resumed = dispatch.build_command(
spec,
"continue",
Path("/workspace"),
"session-1",
Path("/attempt"),
native_resume_session=Path("/attempt/session.jsonl"),
)
self.assertEqual(command, ["runner", "/workspace", "opaque-model", "session-1", "/attempt", "do work"])
self.assertEqual(resumed, ["runner", "resume", "/attempt/session.jsonl", "continue"])
def test_preflight_checks_executable_and_optional_probe(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
value = catalog_value("/bin/true")
value["targets"]["primary"]["runtime"]["preflight_command"] = ["/bin/true", "--check"]
catalog = write_catalog(root, value)
dispatch.preflight_execution_catalog(catalog)
def test_preflight_rejects_missing_command(self):
with TemporaryDirectory() as tmp:
catalog = write_catalog(Path(tmp), catalog_value("definitely-missing-command"))
with self.assertRaisesRegex(dispatch.ExecutionDecisionError, "command not found"):
dispatch.preflight_execution_catalog(catalog)
def test_persisted_decision_failover_uses_next_runtime_target(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
catalog = write_catalog(root)
plan = write_plan(root)
task = task_from_plan(root, plan)
dispatch.EXECUTION_CATALOG_PATH = catalog
with mock.patch.dict(os.environ, {"XDG_STATE_HOME": str(root / "state")}):
store = dispatch.StateStore(root)
try:
initial, first_spec = dispatch.persisted_execution_decision(store, task, stage="worker")
failed, second_spec = dispatch.persisted_execution_decision(
store,
task,
stage="worker",
transition="failover",
failure_class="provider-quota",
)
finally:
store.close()
self.assertEqual(initial["selected"]["target_id"], "primary")
self.assertEqual(first_spec.model, "model-primary")
self.assertEqual(failed["selected"]["target_id"], "alternate")
self.assertEqual(second_spec.model, "model-alternate")
self.assertNotIn("quota", failed)
def test_retry_blocked_marks_failover_without_quota_state(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
catalog = write_catalog(root)
plan = write_plan(root)
task = task_from_plan(root, plan)
dispatch.EXECUTION_CATALOG_PATH = catalog
with mock.patch.dict(os.environ, {"XDG_STATE_HOME": str(root / "state")}):
store = dispatch.StateStore(root)
try:
decision, _ = dispatch.persisted_execution_decision(store, task, stage="worker")
state = store.task_state(task)
state.update(
blocked="runtime failure",
blocker_evidence={
"role": "worker",
"failure_class": "provider-quota",
"locator": "/tmp/locator.json",
"selected": decision["selected"],
"work_unit_id": decision["work_unit_id"],
},
)
store.save()
store.mark_retry_failover("group")
state = store.task_state(task)
finally:
store.close()
self.assertTrue(state["retry_failover_pending"])
self.assertNotIn("quota_snapshot", state)
self.assertNotIn("retry_quota_refresh_pending", state)
def test_runtime_error_classifier_keeps_provider_quota(self):
failure, evidence = dispatch.classify_failure_with_evidence(
"HTTP 429 resource exhausted: quota reached"
)
self.assertEqual(failure, "provider-quota")
self.assertIsNotNone(evidence)
def test_generic_json_terminal_diagnostic_has_no_agent_branch(self):
diagnostic = dispatch.terminal_diagnostic(
"opaque-agent",
"stdout",
json.dumps({"type": "turn.failed", "error": {"code": 429}}),
)
self.assertIn("429", diagnostic or "")
self.assertIsNone(
dispatch.terminal_diagnostic(
"opaque-agent",
"stdout",
json.dumps({"type": "message", "text": "quota design notes"}),
)
)
def test_catalog_source_is_in_runtime_audit_evidence(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
catalog = write_catalog(root)
plan = write_plan(root)
selector = dispatch._selector_module()
decision = selector.select_execution_target(plan, catalog_path=catalog)
evidence = dispatch.selector_runtime_evidence(decision)
self.assertEqual(evidence["catalog"]["source"], str(catalog.resolve()))
self.assertNotIn("quota", evidence)
class GenericDispatcherContractTests(unittest.TestCase):
def test_parallel_limit_contract(self):
self.assertEqual(dispatch.validated_max_parallel(0), 0)
self.assertEqual(dispatch.validated_max_parallel(3), 3)
with self.assertRaises(ValueError):
dispatch.validated_max_parallel(-1)
def test_modified_files_summary_is_canonicalized(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
plan = write_plan(root)
write_set, diagnostics = dispatch.inspect_write_set(plan, root)
self.assertEqual(diagnostics, [])
self.assertEqual(write_set, {str((root / "src/item.txt").resolve())})
def test_outside_workspace_claim_is_rejected(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
plan = write_plan(root)
plan.write_text(
plan.read_text(encoding="utf-8").replace("`src/item.txt`", "`../outside.txt`"),
encoding="utf-8",
)
_, diagnostics = dispatch.inspect_write_set(plan, root)
self.assertTrue(any("outside" in item.lower() or "workspace" in item.lower() for item in diagnostics))
def test_selector_evidence_uses_agent_model_fields(self):
decision = {
"work_unit_id": "group/01::plan-0::tag-API",
"selected": {"target_id": "a", "agent": "runner", "model": "model"},
"candidates": [
{"candidate_rank": 1, "target_id": "a", "agent": "runner", "model": "model"}
],
"decision": {"rule_id": "rule", "policy_priority": 1, "reason_codes": []},
"transition": {"trigger": "initial"},
}
lines = dispatch.selector_evidence_lines(decision)
self.assertIn("candidates=#1:runner/model", lines)
self.assertFalse(any("quota" in line for line in lines))
def test_validate_plan_mode_does_not_require_catalog(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
plan = write_plan(root)
completed = subprocess.run(
[sys.executable, str(SCRIPT), "--workspace", str(root), "--validate-plan", str(plan)],
capture_output=True,
text=True,
env={key: value for key, value in os.environ.items() if key != "AGENT_TASK_EXECUTION_CATALOG"},
check=False,
)
self.assertEqual(completed.returncode, 0, completed.stderr)
def test_dry_run_requires_catalog(self):
with TemporaryDirectory() as tmp:
completed = subprocess.run(
[sys.executable, str(SCRIPT), "--workspace", tmp, "--dry-run"],
capture_output=True,
text=True,
env={key: value for key, value in os.environ.items() if key != "AGENT_TASK_EXECUTION_CATALOG"},
check=False,
)
self.assertEqual(completed.returncode, 2)
self.assertIn("missing_execution_catalog", completed.stderr)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,219 @@
import ast
import asyncio
import importlib.util
import io
import json
import os
import re
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SCRIPT = Path(__file__).parents[1] / "scripts" / "dispatch.py"
loaded = sys.modules.get("agent_task_dispatch")
if loaded is not None:
dispatch = loaded
else:
SPEC = importlib.util.spec_from_file_location("agent_task_dispatch", SCRIPT)
assert SPEC and SPEC.loader
dispatch = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = dispatch
SPEC.loader.exec_module(dispatch)
def make_test_task(root: Path) -> dispatch.Task:
plan = root / "PLAN-local-G05.md"
review = root / "CODE_REVIEW-local-G05.md"
plan.write_text("<!-- task=test plan=0 tag=TEST -->\n", encoding="utf-8")
review.write_text("<!-- task=test plan=0 tag=TEST -->\n", encoding="utf-8")
return dispatch.Task(
name="test",
directory=root,
plan=plan,
review=review,
user_review=None,
recovery=False,
lane="local",
grade=5,
)
class ObservationOutputTest(unittest.TestCase):
def test_banner_preserves_existing_format_and_nested_task_identity(self):
buffer = io.StringIO()
with mock.patch("sys.stdout", buffer):
dispatch.banner("START", "group/subtask/task_name", ["line 1", "line 2"])
output = buffer.getvalue()
expected = (
"------------------------------------------\n"
"START: task_name\n"
"------------------------------------------\n"
"task=group/subtask/task_name\n"
"line 1\n"
"line 2\n"
)
self.assertEqual(output, expected)
buffer_flat = io.StringIO()
with mock.patch("sys.stdout", buffer_flat):
dispatch.banner("START", "task_name")
output_flat = buffer_flat.getvalue()
expected_flat = (
"------------------------------------------\n"
"START: task_name\n"
"------------------------------------------\n"
)
self.assertEqual(output_flat, expected_flat)
def test_attempt_event_is_one_flushed_stdout_line(self):
buffer = io.StringIO()
with mock.patch("sys.stdout", buffer):
dispatch.attempt_event("[test-prefix]", "event message detail")
output = buffer.getvalue()
self.assertEqual(output, "[test-prefix] event message detail\n")
def test_dispatch_compatibility_aliases_point_to_observation_module(self):
self.assertEqual(dispatch.SEP, dispatch.observation.SEP)
self.assertIs(dispatch.banner, dispatch.observation.banner)
self.assertIs(dispatch.attempt_event, dispatch.observation.attempt_event)
def test_observation_module_identity_is_reused(self):
module1 = dispatch.load_sibling_observation_module()
module2 = dispatch.load_sibling_observation_module()
self.assertIs(module1, module2)
self.assertIs(module1, sys.modules["agent_task_dispatcher_observation"])
def test_dispatch_has_no_direct_stdout_print_calls(self):
source = SCRIPT.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(SCRIPT))
stdout_prints = []
for node in ast.walk(tree):
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name) and func.id == "print":
is_stderr = False
for kw in node.keywords:
if kw.arg == "file":
val = kw.value
if (
isinstance(val, ast.Attribute)
and isinstance(val.value, ast.Name)
and val.value.id == "sys"
and val.attr == "stderr"
):
is_stderr = True
break
if not is_stderr:
stdout_prints.append(node.lineno)
self.assertEqual(
stdout_prints,
[],
f"found direct stdout print() calls on lines: {stdout_prints}",
)
class ObservationInvokeIntegrationTest(unittest.IsolatedAsyncioTestCase):
async def test_heartbeat_and_child_output_stay_in_logs_not_user_event_stream(self):
with tempfile.TemporaryDirectory() as temporary:
workspace = Path(temporary)
(workspace / ".git").mkdir()
task = make_test_task(workspace)
store = dispatch.StateStore(workspace)
session_id = "11111111-1111-1111-1111-111111111111"
def command_for(
spec,
prompt,
cwd,
actual_session_id,
attempt_dir,
native_resume_session=None,
):
self.assertEqual(actual_session_id, session_id)
native = attempt_dir / "native-sessions" / f"session_{session_id}.jsonl"
child = (
"from pathlib import Path\n"
"import sys,time\n"
"path = Path(sys.argv[1])\n"
"path.parent.mkdir(parents=True, exist_ok=True)\n"
"path.write_text("
"'{\"type\":\"session\",\"version\":3,\"id\":\"test\","
"\"timestamp\":\"2026-07-25T00:00:00.000Z\","
"\"cwd\":\"/tmp/test\"}\\n', encoding='utf-8')\n"
"time.sleep(0.05)\n"
"print('done', flush=True)\n"
)
return [sys.executable, "-c", child, str(native)]
spec = dispatch.AgentSpec(
"runtime-agent",
"runtime-model",
"runtime-target",
native_resume=True,
target_id="runtime-target",
execution_class="local_model",
runtime={
"command": ["runtime-command", "{prompt}"],
"session_path": "native-sessions/session_{session_id}.jsonl",
"native_session_monitor": True,
"output_format": "text",
},
)
try:
with (
mock.patch.object(dispatch, "build_command", side_effect=command_for),
mock.patch.object(dispatch.uuid, "uuid4", return_value=session_id),
mock.patch.object(dispatch, "STREAM_HEARTBEAT_SECONDS", 0.01),
mock.patch("builtins.print") as print_mock,
):
rc, failure, locator = await dispatch.invoke(
workspace, store, task, "review", spec, "Reply briefly."
)
finally:
store.close()
self.assertEqual(rc, 0)
self.assertIsNone(failure)
record = json.loads(locator.read_text(encoding="utf-8"))
self.assertTrue(record["native_session_path"].endswith(f"{session_id}.jsonl"))
self.assertIsInstance(record["native_session_mtime_ns"], int)
heartbeat = Path(record["heartbeat_log"]).read_text(encoding="utf-8")
self.assertIn("[heartbeat] 작업중...", heartbeat)
self.assertIn("native_session=", heartbeat)
self.assertIn("native_mtime_ns=", heartbeat)
stream = Path(record["stream_log"]).read_text(encoding="utf-8")
self.assertIn("[stdout] done", stream)
self.assertNotIn("[heartbeat]", stream)
normalized = Path(record["normalized_output_log"]).read_text(
encoding="utf-8"
)
self.assertIn("done", normalized)
visible_output = "\n".join(
" ".join(str(value) for value in call.args)
for call in print_mock.call_args_list
)
self.assertIn("locator=", visible_output)
self.assertNotIn("작업중...", visible_output)
self.assertNotIn("done", visible_output)
class SkillObservationContractTest(unittest.TestCase):
def test_dispatcher_owns_observation_and_caller_wakes_only_for_attention(self):
skill = (
Path(__file__).parents[1] / "SKILL.md"
).read_text(encoding="utf-8")
self.assertIn("The dispatcher owns deterministic scheduling, recovery", skill)
self.assertIn("Launch the live dispatcher as one persistent foreground process", skill)
self.assertIn("Do not count internal helper coroutines as agent slots", skill)
self.assertIn("actual stream or native-session progress", skill)
self.assertIn("PID/start-token/process-marker evidence", skill)
self.assertIn("never queries quota before admission", skill)
self.assertIn("confirmed quota/rate-limit error advances directly", skill)
self.assertIn("Common owns no default agent, model, provider, or route catalog", skill)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,179 @@
import importlib.util
import json
import sys
import unittest
from datetime import datetime, timezone
from pathlib import Path
from tempfile import TemporaryDirectory
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "execution_target_policy.py"
SPEC = importlib.util.spec_from_file_location("execution_target_policy_test", SCRIPT)
policy = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
sys.modules[SPEC.name] = policy
SPEC.loader.exec_module(policy)
def catalog_value(*, windows: bool = False) -> dict:
targets = {
"target-a": {
"agent": "runner-a",
"model": "model-a",
"execution_class": "local_model",
"selfcheck_required": True,
"runtime": {
"command": ["runner-a", "--model", "{model}", "{prompt}"],
"resume_command": ["runner-a", "--resume", "{resume_session}", "{prompt}"],
"output_format": "jsonl",
"native_session_monitor": True,
},
},
"target-b": {
"agent": "runner-b",
"model": "model-b",
"execution_class": "cloud_model",
"runtime": {"command": ["runner-b", "{prompt}"]},
},
}
routes = {"worker": {}, "review": {}}
for stage in routes:
for lane in ("local", "cloud"):
for grade in range(1, 11):
route = {
"candidates": ["target-a", "target-b"],
"rule_id": f"{stage}-{lane}-g{grade:02d}",
"policy_priority": grade,
"reason_codes": ["catalog-route"],
}
routes[stage][f"{lane}-G{grade:02d}"] = route
if windows:
routes["worker"]["local-G07"] = {
"windows": [
{
"timezone": "UTC",
"start": "00:00",
"end": "12:00",
"candidates": ["target-a", "target-b"],
"rule_id": "day-route",
},
{
"timezone": "UTC",
"start": "12:00",
"end": "00:00",
"candidates": ["target-b", "target-a"],
"rule_id": "night-route",
},
]
}
return {"schema_version": "1.0", "targets": targets, "routes": routes}
def write_catalog(root: Path, value: dict | None = None) -> Path:
path = root / "catalog.json"
path.write_text(json.dumps(value or catalog_value()), encoding="utf-8")
return path
class ExecutionTargetPolicyTests(unittest.TestCase):
def test_catalog_is_runtime_loaded_and_route_is_complete(self):
with TemporaryDirectory() as tmp:
catalog = policy.load_catalog(write_catalog(Path(tmp)))
decision = policy.select_policy(
catalog=catalog,
stage="worker",
lane="cloud",
grade=3,
evaluated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
self.assertEqual(decision.route_id, "cloud-G03")
self.assertEqual([item.catalog_id for item in decision.candidates], ["target-a", "target-b"])
self.assertEqual(decision.candidates[0].agent, "runner-a")
self.assertEqual(decision.candidates[0].model, "model-a")
self.assertEqual(decision.catalog_revision, catalog.revision)
def test_common_policy_has_no_built_in_catalog(self):
self.assertFalse(hasattr(policy, "CANONICAL_TARGETS"))
self.assertFalse(hasattr(policy, "quota_probe_spec"))
self.assertFalse(hasattr(policy, "promotion_target"))
def test_optional_windows_are_catalog_owned_and_timezone_generic(self):
with TemporaryDirectory() as tmp:
catalog = policy.load_catalog(write_catalog(Path(tmp), catalog_value(windows=True)))
morning = policy.select_policy(
catalog=catalog,
stage="worker",
lane="local",
grade=7,
evaluated_at=datetime(2026, 1, 1, 6, tzinfo=timezone.utc),
)
evening = policy.select_policy(
catalog=catalog,
stage="worker",
lane="local",
grade=7,
evaluated_at=datetime(2026, 1, 1, 18, tzinfo=timezone.utc),
)
self.assertEqual(morning.candidates[0].catalog_id, "target-a")
self.assertEqual(evening.candidates[0].catalog_id, "target-b")
self.assertEqual(morning.rule_id, "day-route")
self.assertEqual(evening.rule_id, "night-route")
def test_catalog_requires_every_stage_lane_grade_route(self):
value = catalog_value()
del value["routes"]["review"]["cloud-G10"]
with TemporaryDirectory() as tmp:
with self.assertRaisesRegex(policy.CatalogError, "cover local/cloud G01..G10 exactly"):
policy.load_catalog(write_catalog(Path(tmp), value))
def test_unknown_target_and_unknown_template_field_are_rejected(self):
unknown_target = catalog_value()
unknown_target["routes"]["worker"]["local-G01"]["candidates"] = ["missing"]
bad_template = catalog_value()
bad_template["targets"]["target-a"]["runtime"]["command"] = ["runner", "{provider_secret}"]
with TemporaryDirectory() as tmp:
root = Path(tmp)
with self.assertRaisesRegex(policy.CatalogError, "unknown targets"):
policy.load_catalog(write_catalog(root, unknown_target))
with self.assertRaisesRegex(policy.CatalogError, "unsupported template field"):
policy.load_catalog(write_catalog(root, bad_template))
def test_command_executable_must_be_literal_for_preflight(self):
value = catalog_value()
value["targets"]["target-a"]["runtime"]["command"] = [
"{workspace}",
"{prompt}",
]
with TemporaryDirectory() as tmp:
with self.assertRaisesRegex(policy.CatalogError, "executable must be a literal"):
policy.load_catalog(write_catalog(Path(tmp), value))
def test_catalog_revision_changes_with_content(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
path = write_catalog(root)
first = policy.load_catalog(path)
changed = catalog_value()
changed["targets"]["target-a"]["model"] = "model-a-next"
path.write_text(json.dumps(changed), encoding="utf-8")
second = policy.load_catalog(path)
self.assertNotEqual(first.revision, second.revision)
def test_invalid_route_inputs_are_rejected(self):
with TemporaryDirectory() as tmp:
catalog = policy.load_catalog(write_catalog(Path(tmp)))
for values in (
{"stage": "selfcheck", "lane": "local", "grade": 1},
{"stage": "worker", "lane": "hybrid", "grade": 1},
{"stage": "worker", "lane": "local", "grade": 0},
):
with self.subTest(values=values), self.assertRaises(ValueError):
policy.select_policy(
catalog=catalog,
evaluated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
**values,
)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,214 @@
import importlib.util
import json
import os
import subprocess
import sys
import unittest
from datetime import datetime, timezone
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import mock
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "select_execution_target.py"
SPEC = importlib.util.spec_from_file_location("select_execution_target_test", SCRIPT)
selector = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
sys.modules[SPEC.name] = selector
SPEC.loader.exec_module(selector)
def catalog_value() -> dict:
targets = {
"first": {
"agent": "agent-one",
"model": "model-one",
"execution_class": "local_model",
"selfcheck_required": True,
"runtime": {"command": ["agent-one", "{prompt}"]},
},
"second": {
"agent": "agent-two",
"model": "model-two",
"execution_class": "cloud_model",
"runtime": {"command": ["agent-two", "--model", "{model}", "{prompt}"]},
},
}
routes = {"worker": {}, "review": {}}
for stage in routes:
for lane in ("local", "cloud"):
for grade in range(1, 11):
routes[stage][f"{lane}-G{grade:02d}"] = {
"candidates": ["first", "second"],
"rule_id": f"{stage}-{lane}-{grade:02d}",
"reason_codes": ["runtime-catalog"],
}
return {"schema_version": "1.0", "targets": targets, "routes": routes}
def write_catalog(root: Path, value: dict | None = None) -> Path:
path = root / "catalog.json"
path.write_text(json.dumps(value or catalog_value()), encoding="utf-8")
return path
def write_task(
root: Path,
*,
kind: str = "PLAN",
lane: str = "cloud",
grade: int = 5,
task: str = "group/01_task",
milestone_task: str | None = None,
) -> Path:
milestone = f" milestone-task={milestone_task}" if milestone_task else ""
path = root / f"{kind}-{lane}-G{grade:02d}.md"
path.write_text(
f"<!-- task={task} plan=0 tag=API{milestone} -->\n\n# Task\n",
encoding="utf-8",
)
return path
class SelectorTests(unittest.TestCase):
def test_catalog_must_be_injected(self):
with TemporaryDirectory() as tmp, mock.patch.dict(os.environ, {}, clear=True):
task = write_task(Path(tmp))
with self.assertRaises(selector.SelectorInputError) as ctx:
selector.select_execution_target(task)
self.assertEqual(ctx.exception.code, "missing_execution_catalog")
def test_initial_decision_contains_catalog_evidence_and_no_quota(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
catalog = write_catalog(root)
result = selector.select_execution_target(
write_task(root),
catalog_path=catalog,
evaluated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
self.assertEqual(result["schema_version"], "2.0")
self.assertEqual(result["selected"]["target_id"], "first")
self.assertEqual(result["selected"]["agent"], "agent-one")
self.assertEqual(result["selected"]["model"], "model-one")
self.assertEqual(result["catalog"]["source"], str(catalog.resolve()))
self.assertEqual([item["target_id"] for item in result["candidates"]], ["first", "second"])
self.assertNotIn("quota", result)
self.assertTrue(all("quota_status" not in item for item in result["candidates"]))
def test_catalog_can_be_injected_by_environment(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
catalog = write_catalog(root)
with mock.patch.dict(os.environ, {selector.CATALOG_ENV: str(catalog)}):
result = selector.select_execution_target(write_task(root))
self.assertEqual(result["catalog"]["source"], str(catalog.resolve()))
def test_runtime_quota_error_moves_to_next_catalog_target(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
catalog = write_catalog(root)
task = write_task(root)
first = selector.select_execution_target(task, catalog_path=catalog)
second = selector.select_execution_target(
task,
catalog_path=catalog,
transition="failover",
prior_decision=first,
failure_class="provider-quota",
)
self.assertEqual(second["selected"]["target_id"], "second")
self.assertEqual(second["transition"]["trigger"], "failover")
self.assertEqual(second["transition"]["previous_target"]["target_id"], "first")
def test_failover_requires_runtime_failure_and_unused_candidate(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
catalog = write_catalog(root)
task = write_task(root)
first = selector.select_execution_target(task, catalog_path=catalog)
with self.assertRaises(selector.SelectorInputError) as ctx:
selector.select_execution_target(
task,
catalog_path=catalog,
transition="failover",
prior_decision=first,
failure_class="generic-error",
)
self.assertEqual(ctx.exception.code, "unqualified_failover")
second = selector.select_execution_target(
task,
catalog_path=catalog,
transition="failover",
prior_decision=first,
failure_class="model-unavailable",
)
with self.assertRaises(selector.SelectorInputError) as ctx:
selector.select_execution_target(
task,
catalog_path=catalog,
transition="failover",
prior_decision=second,
failure_class="provider-quota",
)
self.assertEqual(ctx.exception.code, "no_failover_candidate")
def test_resume_pins_target_and_catalog_revision(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
catalog = write_catalog(root)
task = write_task(root)
first = selector.select_execution_target(task, catalog_path=catalog)
resumed = selector.select_execution_target(
task,
catalog_path=catalog,
transition="resume",
prior_decision=first,
)
changed = catalog_value()
changed["targets"]["first"]["model"] = "changed-model"
catalog.write_text(json.dumps(changed), encoding="utf-8")
with self.assertRaises(selector.SelectorInputError) as ctx:
selector.select_execution_target(
task,
catalog_path=catalog,
transition="resume",
prior_decision=resumed,
)
self.assertTrue(resumed["decision"]["pinned"])
self.assertEqual(ctx.exception.code, "catalog_revision_mismatch")
def test_stage_and_milestone_header_contract(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
catalog = write_catalog(root)
review = write_task(root, kind="CODE_REVIEW", lane="local", grade=2)
self.assertEqual(
selector.select_execution_target(review, catalog_path=catalog)["stage"],
"review",
)
with self.assertRaises(selector.SelectorInputError) as ctx:
selector.select_execution_target(review, stage="worker", catalog_path=catalog)
self.assertEqual(ctx.exception.code, "stage_mismatch")
missing = write_task(root, task="m-feature/01_task")
with self.assertRaises(selector.SelectorInputError) as ctx:
selector.select_execution_target(missing, catalog_path=catalog)
self.assertEqual(ctx.exception.code, "missing_milestone_task")
def test_cli_returns_structured_catalog_error(self):
with TemporaryDirectory() as tmp:
task = write_task(Path(tmp))
completed = subprocess.run(
[sys.executable, str(SCRIPT), str(task)],
capture_output=True,
text=True,
env={key: value for key, value in os.environ.items() if key != selector.CATALOG_ENV},
check=False,
)
self.assertEqual(completed.returncode, 2)
self.assertEqual(completed.stdout, "")
self.assertEqual(json.loads(completed.stderr)["error"]["code"], "missing_execution_catalog")
if __name__ == "__main__":
unittest.main()

View file

@ -1,6 +1,6 @@
---
name: plan
description: Analyze the current repository and write a detailed PLAN-{build_lane}-GNN.md plus CODE_REVIEW-{review_lane}-GNN.md stub for implementation work. Use for every feature, refactor, bug fix, and code-review WARN/FAIL follow-up that enters the plan-code-review loop. Every initial or follow-up pair must run finalize-task-routing after analysis and before routed filenames are chosen. Milestone-linked work uses a reserved m-prefixed task group so runtime can route PASS completion events to update-roadmap.
description: Analyze the current repository and write a detailed PLAN-{build_lane}-GNN.md plus CODE_REVIEW-{review_lane}-GNN.md stub for implementation work. Use for every feature, refactor, bug fix, and code-review WARN/FAIL follow-up that enters the plan-code-review loop. Every initial or follow-up pair must run finalize-task-routing after analysis and before routed filenames are chosen. Milestone-linked work uses an m-prefixed task group and first-line milestone-task ids so PASS logs can be aggregated by sync-milestone-workstate.
---
# Plan
@ -13,10 +13,10 @@ Create the planning artifacts for the implementation loop:
plan skill -> analysis -> finalize-task-routing -> PLAN-{build_lane}-GNN.md + CODE_REVIEW-{review_lane}-GNN.md stub
implementation -> code changes + filled implementation evidence in CODE_REVIEW-{review_lane}-GNN.md
code-review skill -> verdict + archive, complete.log and task-directory archive move, USER_REVIEW.md, or mandatory plan-skill follow-up
runtime -> for m-prefixed PASS completion events, state check and optional update-roadmap call
runtime -> for m-prefixed PASS completion events, aggregate complete.log evidence with sync-milestone-workstate
```
`code-review` may stop the automatic loop with `USER_REVIEW.md` only when a selected Milestone `구현 잠금 > 결정 필요` item blocks implementation. Repeated non-PASS reviews, test environment blockers, external environment/secret/service setup, generic scope changes, and verification evidence gaps are not user-review reasons by themselves; they should become normal follow-up plans or unresolved verification evidence. Plan creation after `USER_REVIEW.md` requires the linked Milestone decision to be resolved. If the decision closes the task as complete/PASS, code-review resolves `USER_REVIEW.md`, writes `complete.log`, and archives the task instead of creating a new plan.
`code-review` may stop the automatic loop with `USER_REVIEW.md` for either a selected Milestone `구현 잠금 > 결정 필요` item (`milestone-lock`) or required external verification that cannot proceed without a user-controlled runner, device, credential, interactive session, evidence handoff, or explicit authorization (`external-execution`). A current-host mismatch or missing command is not enough when a repository-declared runner or authorized executor can perform the step automatically. Repeated non-PASS reviews and missing evidence are not user-review reasons by themselves. Plan creation after `USER_REVIEW.md` requires its recorded user action or decision to be resolved. If that resolution closes the task as complete/PASS, code-review writes `complete.log` and archives the task instead of creating a new plan.
The plan file and review stub must be self-sufficient for implementation agents that do not read this skill. Implementing agents fill the active review's implementation evidence. They never decide whether user review is needed, write a user-review request, ask the user for a decision, create `USER_REVIEW.md`, or modify runtime-owned artifacts; those control-plane responsibilities belong to the official code-review skill and runtime.
@ -54,10 +54,11 @@ Filename rules:
Role boundary rules:
- Keep root cause, scope, ownership, and next-state decisions under plan/review, and validate their artifacts directly; dispatcher use is optional. Let implementing agents execute the packet without reinterpreting findings or changing its owner/write boundary.
- Implementing agents fill implementation-owned `CODE_REVIEW-*-G??.md` sections, keep active files in place, and report ready for review.
- If implementation cannot continue, implementing agents record the exact blocker, attempted commands/output, and resume condition only in `Verification Results` or `Deviations from Plan` (legacy: `검증 결과` or `계획 대비 변경 사항`), then leave the active files in place for official review.
- During implementation, do not ask the user directly, present choices, call user-input tools, or create control-plane stop files. The official reviewer owns all next-state classification.
- Required UI evidence capture that needs a user-owned device, emulator, permission, secret, or interactive access unavailable to the agent is a verification blocker, not a user-review reason by itself. Record attempted commands and blocker evidence in `Verification Results` or `Deviations from Plan` (legacy: `검증 결과` or `계획 대비 변경 사항`) so code-review can write a normal follow-up or unresolved verification report.
- Required evidence capture that needs a user-owned runner, device, emulator, permission, secret, interactive access, evidence handoff, or explicit external authorization unavailable to the agent is a verification blocker for the implementing agent. Record the declared execution target, attempted routing/preflight commands, actual output, authorization state, and resume condition in `Verification Results` or `Deviations from Plan` (legacy: `검증 결과` or `계획 대비 변경 사항`) so the official reviewer can evaluate the `external-execution` gate.
- Finalization (`Code Review Result` [legacy: `코드리뷰 결과`], plan/review log rename, `complete.log`, task artifact archive moves, review-only checklist) is code-review-skill only.
Split decision policy:
@ -80,7 +81,7 @@ Task directory naming rules:
- A normal single-plan task uses `agent-task/{task_group}/` with a short snake_case category name, e.g. `agent-task/refactoring/`.
- If the plan is based on a selected active Milestone, use `agent-task/m-<milestone-slug>/` as the task group. Do not include the Phase slug, Epic id, Task id, or a separate task slug in the task group.
- `m-<milestone-slug>` is a reserved top-level task group namespace for Milestone-linked work. Non-roadmap tasks must not use `m-`.
- Runtime completion-event routing for `m-*` reads only the top-level `{task_group}` name. It resolves `<milestone-slug>` by matching exactly one active file at `agent-roadmap/phase/*/milestones/<milestone-slug>.md`; archive paths are not target candidates.
- Runtime completion-event routing for `m-*` reads the top-level `{task_group}` name and the first-line `milestone-task` ids preserved in `complete.log`. It resolves `<milestone-slug>` by matching exactly one active file at `agent-roadmap/phase/*/milestones/<milestone-slug>.md`; archive paths are not target candidates.
- When split gates require decomposition, create one shared category folder and multiple subtask directories under it. Each subtask directory owns exactly one normal active plan file and one normal active review stub.
- Multi-plan output is a set of independent `PLAN-{build_lane}-GNN.md` + `CODE_REVIEW-{review_lane}-GNN.md` pairs across `agent-task/{task_group}/{subtask_dir}/` folders, not multiple plan files inside one folder.
- Multi-plan subtask directory names must start with a stable two-digit task index. The index must increase across sibling subtask directories for sorting, but it is not a serial execution dependency.
@ -99,10 +100,10 @@ Task directory naming rules:
- For predecessor index `PP`, the only valid archive lookup candidates are `agent-task/archive/*/*/{task_group}/PP_*/complete.log` and `agent-task/archive/*/*/{task_group}/PP+*/complete.log`.
- Archive lookup matches the predecessor index at the start of the archived subtask directory name, such as `01_...` or `01+...`, under the same `{task_group}`. If multiple candidates match one predecessor index, do not choose by guess; record the ambiguity and require a concrete task path or runtime selection.
- Do not treat an archived predecessor as the active task to edit. Archive lookup is only for dependency satisfaction before writing or implementing a dependent split plan.
- Example: split a refactoring common core plus two app integrations under `agent-task/refactoring/` as `01_core`, `02+01_edge_integration`, `03+01_node_integration`. Both integrations depend only on `01_core` and may run in parallel after `01_core` has `complete.log`.
- Example: split a refactoring common core plus two app integrations under `agent-task/refactoring/` as `01_core`, `02+01_app_a_integration`, `03+01_app_b_integration`. Both integrations depend only on `01_core` and may run in parallel after `01_core` has `complete.log`.
- Example: split three sequential tasks under one task group as `01_schema`, `02+01_migration`, `03+02_api`.
- Example: split independent docs/UI plus an integration under one task group as `01_core`, `02+01_db`, `03+02_api`, `04_docs`, `05_ui`, `06+05_integration`; `01_core`, `04_docs`, and `05_ui` can start together, and `06+05_integration` waits only for `05_ui`.
- After a pair is written, preserve its task group and subtask directory name verbatim. Only an explicit `refine-local-plans` run may rename eligible unstarted local siblings by its dependency-order rules.
- After a pair is written, preserve its task group and subtask directory name verbatim. Only an explicit `refine-plans` run may rename eligible unstarted siblings by its dependency-order rules.
Final routing boundary:
@ -111,7 +112,7 @@ Final routing boundary:
- Execute `finalize-task-routing` once for build/review and use only its lane/G/filenames. Apply it to first-pass, follow-up, USER_REVIEW replan, and each split subtask independently.
- Quarantine previous lane/G/score/rationale. Carry only revalidated code, findings, command output, `review_rework_count`, and `evidence_integrity_failure`.
- `needs_evidence` names genuinely missing closure evidence; `blocked` stops file creation. Changed plan facts invalidate the route.
- Only `refine-local-plans` may retain an unstarted local pair's route while splitting it into strict-subset local children.
- Only `refine-plans` may replace an unstarted pair with strict-subset children. It must run `finalize-task-routing` in `isolated-reassessment` mode for every completed child packet and must not retain the parent route.
Directory states:
@ -122,7 +123,7 @@ Directory states:
| `PLAN-*-G??.md` + filled `CODE_REVIEW-*-G??.md` without verdict | Ready for code-review skill |
| `PLAN-*-G??.md` + `CODE_REVIEW-*-G??.md` with appended verdict | Review finalization is pending. Continue only when code-review invokes `prepare-follow-up`; `write` mode must not overwrite this state. |
| `complete.log` + `*.log` files | Task complete (PASS or user-review-resolved PASS), before final task-directory archive move |
| `USER_REVIEW.md` + `*.log` files | Automatic loop stopped; linked Milestone lock decision must be resolved before creating another plan |
| `USER_REVIEW.md` + `*.log` files | Automatic loop stopped; its recorded Milestone decision or external-execution user action must be resolved before creating another plan |
| `agent-task/archive/YYYY/MM/{task_name}/complete.log` + `*.log` files | Archived completed task path (PASS or user-review-resolved PASS); not active |
| Only `*.log` files (no `complete.log`) | Inspect the newest review log. A verdict with no required next state is post-archive finalization pending; otherwise the task is terminated mid-loop or abandoned. |
@ -148,7 +149,7 @@ Also note active user-review stops, excluding `agent-task/archive/**`:
The routed plan file is the loop entry point. A missing active plan normally means only that no plan has been started for a new task; do not create task files for casual analysis, status, or review requests unless the user explicitly asks for a plan.
If no active plan exists but one or more `USER_REVIEW.md` files exist, report that the linked Milestone decision is required and list the paths unless one path is explicitly selected for resolution or replanning. If a selected active task directory contains `USER_REVIEW.md`, read it before planning. Do not write a new follow-up plan unless the linked Milestone decision has been resolved or the new plan explicitly replans around that recorded decision. When planning resumes from `USER_REVIEW.md`, archive it to `user_review_N.log` in the same task directory before writing the new active plan/review pair, and record the resolved decision in the new plan `Background` or `Analysis` (legacy: `배경` or `분석 결과`).
If no active plan exists but one or more `USER_REVIEW.md` files exist, report the recorded user action or decision and list the paths unless one path is explicitly selected for resolution or replanning. If a selected active task directory contains `USER_REVIEW.md`, read it before planning. Do not write a new follow-up plan unless the recorded action or decision has been resolved or the new plan explicitly replans around that resolution. When planning resumes from `USER_REVIEW.md`, archive it to `user_review_N.log` in the same task directory before writing the new active plan/review pair, and record the resolved action or decision in the new plan `Background` or `Analysis` (legacy: `배경` or `분석 결과`).
If a selected task directory contains both `USER_REVIEW.md` and active `PLAN-*-G??.md` or `CODE_REVIEW-*-G??.md`, report an inconsistent loop state and do not overwrite either state until a later explicit command selects either user-review resolution or the active plan/review path.
@ -157,9 +158,11 @@ If the selected review already has an appended verdict, accept it only in `prepa
로드맵 확인:
- `agent-roadmap/current.md`는 브랜치별 로컬 포인터다. 있으면 구현 계획 파일을 만들기 전에 읽고, 사용자 요청, 브랜치, 변경 경로를 기준으로 관련 Phase와 Milestone을 선택한다.
- `agent-roadmap/priority-queue.md`가 있으면 명시 target이 없는 구현 계획에서 Phase를 가로지르는 후보 순서를 확인하기 위해 읽는다. 큐 순서는 우선순위 참고이며, 상태/잠금/기능 원본은 각 Milestone 문서다.
- 사용자가 target Milestone을 명시하지 않았고 요청이 "다음 작업" 또는 일반 구현 계획이면 `priority-queue.md`의 위에서 아래 순서 중 요청과 맞고 활성 경로에 존재하는 첫 Milestone을 우선 후보로 둔다.
- `priority-queue.md` 링크가 깨졌으면 Milestone을 추측해 계획하지 말고 `update-roadmap`으로 큐 재정렬/재생성이 필요하다고 보고한다.
- `agent-roadmap/priority-queue.md`가 있으면 명시 target이 없는 구현 계획에서 prefix별 후보와 차단 예외를 확인하기 위해 읽는다. 상태/잠금/기능 원본은 각 Milestone 문서다.
- 각 prefix에서 가장 작은 active index 하나만 lane head 후보로 둔다. `선행 차단`이 남은 후보는 제외하고, `동시 차단` target이 현재 실행 중이면 제외한다. 다른 prefix의 열린 head들은 병렬 후보이며 문서상 위쪽 group은 target 없는 "다음 작업"의 tie-breaker로만 쓴다.
- 사용자가 Milestone을 명시해도 같은 prefix의 더 작은 active index, `선행 차단`, 현재 진행 중인 `동시 차단` target이 있으면 plan을 만들지 않고 실행 순서 차단을 보고한다.
- queue의 설명은 후보 이해에 유지하되 scheduling 판단에는 tag, link target, exact `선행 차단`/`동시 차단` label만 사용한다.
- `priority-queue.md` 링크, 실행 태그, group-prefix, blocker 참조가 깨졌으면 Milestone을 추측해 계획하지 말고 `update-roadmap`으로 큐 정리가 필요하다고 보고한다.
- `agent-roadmap/`이 있는데 `current.md`가 없으면 `agent-ops/skills/common/_templates/roadmap-current-template.md` 형식으로 로컬 파일을 만들거나, `ROADMAP.md`의 Phase 흐름과 관련 `PHASE.md`에서 후보를 고른 뒤 로컬 current를 채운다. current 없음만으로 일반 task routing으로 빠지지 않는다.
- `current.md``agent-roadmap/archive/**`를 가리키면 해당 문서는 읽지 말고 활성 Phase/Milestone이 아니라고 보고한다.
- 선택한 Phase를 한 번 읽어 Phase 목표, Milestone 흐름, Phase 경계를 확인한다.
@ -177,8 +180,10 @@ If the selected review already has an appended verdict, accept it only in `prepa
- 기능 Task에 `검증:`이 있으면 구현 계획의 같은 plan item 안에 해당 검증을 포함한다. 검증이 명시되지 않은 기능 Task에는 억지 검증 항목을 만들지 말고, 필요한 일반 빌드/회귀 확인만 최종 검증에 둔다.
- 선택한 활성 Milestone 범위에 속하는 구현 계획이면 `{task_group}``m-<milestone-slug>`로 정한다. `<milestone-slug>`는 선택한 Milestone 경로의 파일명에서 `.md`를 제거한 값이다.
- 같은 Milestone에서 split work가 필요하면 기존 split 규칙 그대로 `agent-task/m-<milestone-slug>/<subtask_dir>/` 아래에 계획 파일을 만든다.
- Milestone 기능 Task 완료를 목표로 하는 계획이면 `Roadmap Targets` 섹션에 활성 Milestone 경로와 완료 대상 Task id를 고정한다. 이 섹션은 `complete.log``Roadmap Completion` 근거로 복사되어 `update-roadmap`이 해당 Task만 체크하는 anchor가 된다. 이 섹션은 `{task_group}`이 해당 Milestone slug의 `m-<milestone-slug>`일 때만 쓴다.
- Milestone 작업이 아니거나, Milestone 안의 조사/하위 구현처럼 특정 기능 Task 완료를 주장하지 않는 계획이면 `Roadmap Targets` 섹션을 쓰지 않는다. 섹션이 없으면 PASS 후에도 roadmap Task 체크를 하지 않는다.
- Milestone 작업 계획은 첫 줄에 `milestone-task=<task-id>[,<task-id>...]`를 넣어 이 작업이 기여하는 기존 기능 Task id를 고정한다. id는 선택한 활성 Milestone `기능` 섹션에 실제로 존재해야 하고, `rules-roadmap.md`의 item-id 문법을 따르며, 중복 없이 쉼표로 구분하고 공백을 넣지 않는다.
- `milestone-task`는 PASS 즉시 체크할 완료 주장이나 plan 하나당 Task 하나라는 뜻이 아니다. 여러 plan이 같은 id에 기여할 수 있고 한 plan이 여러 id에 기여할 수 있다. 이후 `sync-milestone-workstate`가 같은 Milestone task group의 모든 `complete.log`를 id별로 모아 Task 설명·검증·SDD evidence 충족 여부를 평가한다.
- Milestone 범위의 하위 구현이나 조사도 관련 기능 Task id가 명확하면 같은 id를 기록한다. 관련 id를 정할 수 없다면 `m-*` task group으로 계획하지 말고, 먼저 Milestone 기능 Task를 보강하거나 비마일스톤 task group으로 분리한다.
- WARN/FAIL follow-up은 범위가 그대로면 이전 `milestone-task` id 목록을 정확히 유지한다. 범위를 바꾸는 경우에만 현재 Milestone과 SDD mapping을 다시 확인해 id를 명시적으로 교정하며, id가 조용히 누락되거나 다른 id로 바뀌면 안 된다.
- `agent-roadmap/` 디렉터리가 없으면 기존 task routing 규칙대로 진행한다.
Use short snake_case task group names for non-roadmap work, e.g. `api_refactor`.
@ -196,12 +201,13 @@ Complete all items below before creating active plan/review files. Work through
- [ ] **Assess test coverage** — for each behavior change, explicitly record whether existing tests cover it.
- [ ] **Assess split boundaries once** — reconcile request acceptance with source/tests, then split only where every child has a stable contract and independent PASS verification. Otherwise keep the invariant together; do not gather extra evidence solely to lower routing risk.
- [ ] **Capture recovery signals once** — first-pass uses `review_rework_count=0` and `evidence_integrity_failure=false`. In `prepare-follow-up`, reuse the values already validated and appended by code-review; do not recount verdict history. For another isolated replan, derive them once from the same-task state already loaded for planning, without a routing-only log pass.
- [ ] **Resolve follow-up findings once** — in `prepare-follow-up`, map every inherited Required/Suggested id. Default repository-fixable work to `direct-fix` with exact root-cause files, overriding stale verification-only exclusions. Allow `verified-dependency` only when an exact active PLAN claims those files and task-protocol ordering applies, or when `complete.log` plus fresh evidence proves the failed precondition is satisfied; vague owners or `complete.log` alone are invalid. Set `ownership_closed=true` only after all mappings are proven. Reject unchanged-precondition verification loops. Reuse the existing analysis; add no model, sub-agent, or routing-only pass.
- [ ] **Resolve split predecessor completion** — if the selected or proposed subtask directory has `NN+PP[,QQ...]_...`, resolve each predecessor index under the same task group. Check only the active and archive candidate patterns defined in the task directory naming rules. Record found active/archive paths, missing predecessors, or ambiguous matches in `Analysis > Split Judgment` (legacy: `분석 결과 > 분할 판단`) and, when order matters, `Dependencies and Execution Order` (legacy: `의존 관계 및 구현 순서`).
- [ ] **Grep all symbol references** — for any renamed or removed symbol, find every call site and import chain.
- [ ] **Check dependency manifests** — before adding any new package, verify its presence in go.mod / package manifest.
- [ ] **Check dependency manifests** — before adding any dependency, inspect the repository's relevant dependency manifest and lockfile, confirm whether it already exists, and follow the repository-native version and update policy.
- [ ] **Pre-check compile issues** — identify missing interface implementations, type mismatches, and broken imports.
- [ ] **Verify verification commands** — confirm that the final verification commands actually run in this repository layout.
- [ ] **Stabilize fragile verification** — for search or generated-output checks, choose deterministic commands up front, such as `rg --sort path`, and decide whether cached test output is acceptable or `-count=1` is required.
- [ ] **Stabilize fragile verification** — for search or generated-output checks, choose deterministic commands up front, such as `rg --sort path`, and decide whether cached test output is acceptable or the repository's test runner must use its fresh-run or cache-bypass option.
- [ ] **Derive routing signals once** — treat each completed in-memory PLAN as the worker packet. From facts already collected, record `large_indivisible_context`, positive matched loop-risk names/count, and recovery signals. Do not reread files, prove unmatched signatures false, or aggregate parent/sibling risk for routing.
## Step 3 - Finalize Task Routing
@ -221,7 +227,7 @@ In `write` mode, complete this step before writing new active files. In `prepare
- In `write` mode, ensure `.gitignore` has the Agent-Ops managed gitignore block before renaming any active file to `*.log` or creating local `agent-roadmap/current.md`. Prefer `source agent-ops/bin/ai-ignore.sh && agent_ops_ensure_gitignore_task_artifact_block .gitignore`; if the helper is unavailable, add or update a block containing `!agent-task/`, `!agent-task/**/`, `!agent-task/**/*.md`, `!agent-task/**/*.log`, and `agent-roadmap/current.md`. In `prepare-follow-up` mode, only inspect the block and return `gitignore_repair_needed: true|false`; code-review performs any repair after preparation.
- Count existing `plan_*.log` as `current_plan_archive_number`. If an active plan exists, parse `current_build_lane` and `current_build_grade` from that active filename and set `current_plan_archive_name=plan_{current_build_lane}_{current_build_grade}_{current_plan_archive_number}.log`. Require that destination not to exist. In `write` mode rename that exact active file to the calculated name; in `prepare-follow-up` only return the name and number. Never use the newly routed build lane/grade to archive the old active plan.
- Count existing `code_review_*.log` as `current_review_archive_number`. If an active review exists, parse `current_review_lane` and `current_review_grade` from that active filename and set `current_review_archive_name=code_review_{current_review_lane}_{current_review_grade}_{current_review_archive_number}.log`. Require that destination not to exist. In `write` mode rename that exact active file to the calculated name; in `prepare-follow-up` only return the name and number. Never use the newly routed review lane/grade to archive the old active review.
- Count existing `user_review_*.log` as `current_user_review_archive_number`. If `USER_REVIEW.md` exists and the linked Milestone decision is resolved for replanning, set `current_user_review_archive_name=user_review_{current_user_review_archive_number}.log`; require that destination not to exist and rename it only in `write` mode.
- Count existing `user_review_*.log` as `current_user_review_archive_number`. If `USER_REVIEW.md` exists and its recorded user action or decision is resolved for replanning, set `current_user_review_archive_name=user_review_{current_user_review_archive_number}.log`; require that destination not to exist and rename it only in `write` mode.
- Compute `post_archive_plan_log_count`, `post_archive_review_log_count`, and `post_archive_user_review_log_count` from the filesystem after actual archive in `write` mode, or from the predicted addition of each current active file in `prepare-follow-up` mode.
Set `plan_number=post_archive_plan_log_count`. The new pair's future archive suffixes are `plan_log_number=post_archive_plan_log_count` and `review_log_number=post_archive_review_log_count`. These are intentionally different concepts from `current_plan_archive_number` and `current_review_archive_number`.
@ -230,10 +236,19 @@ Set `plan_number=post_archive_plan_log_count`. The new pair's future archive suf
Render the complete plan in memory first. In `write` mode, write it to the routed plan basename. In `prepare-follow-up` mode, return the exact rendered body as `prepared_plan` and do not write it.
Header line must be exactly:
Header line must be exactly one of these forms:
```markdown
<!-- task={task_name} plan={plan_number} tag={TAG} -->
<!-- task=m-<milestone-slug>[/<subtask_dir>] plan={plan_number} tag={TAG} milestone-task=<task-id>[,<task-id>...] -->
```
Use the second form for every `m-*` task and the first form for every non-milestone task. The PLAN and review stub first lines must be identical.
Example:
```markdown
<!-- task=m-sample-capability/03+01,02_storage plan=0 tag=API milestone-task=sample-item -->
```
Required sections:
@ -242,20 +257,10 @@ Required sections:
- `For the Implementing Agent`: warn that filling implementation-owned `CODE_REVIEW-*-G??.md` sections is mandatory. Tell the implementer to run verification, fill actual notes/output, keep active files in place, and report ready for review; finalization is code-review-skill only. If blocked, the implementer records only exact blocker evidence, attempted commands/output, and resume conditions in implementation-owned evidence fields. It must not ask the user, call user-input tools, create control-plane stop files, classify the next state, archive logs, or write `complete.log`.
- `Background`: 2-4 sentences explaining why the work is needed.
- `Archive Evidence Snapshot`: include this section only when the plan resumes from `USER_REVIEW.md`, a prior archived review, or any archive evidence. Omit it for first-pass plans with no archive evidence. The section must contain only the archive facts needed to implement without rereading archive by default: prior task/archive paths, verdict, Required/Suggested/Nit summary, affected files, verification evidence, and any roadmap carryover. If exact prior context is still required, cite the specific archive file paths allowed to read; do not ask the implementer to search `agent-task/archive/**` broadly.
- `Roadmap Targets`: include this section only when the plan is intended to complete one or more existing Milestone 기능 Task ids. Omit the section entirely for non-roadmap work or Milestone-adjacent work that should not check a Task on PASS. Format exactly:
```markdown
## Roadmap Targets
- Milestone: `agent-roadmap/phase/<phase-slug>/milestones/<milestone-slug>.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/<phase-slug>/milestones/<milestone-slug>.md)
- Task ids:
- `<task-id>`: <Task text or concise label>
- Completion mode: check-on-pass
```
- `Finding Resolution Map`: for WARN/FAIL follow-ups only, use one row per Required/Suggested id: mode, exact fix/dependency evidence, and changed/satisfied precondition. Put every `direct-fix` file in `Modified Files Summary`. For an unordered dependency, emit no worker packet; return `status=blocked`, `blocked_reason=dependency-wait`, `next-task`, and resume condition.
- `Analysis`: record the findings from Step 2 and the final routed output from Step 3. This section is the written output of the analysis — not a summary, but the actual findings that justify the plan's scope and decisions. Must include all of the following subsections:
- `Files Read`: list every source and test file read during analysis, with path. List verification-context source files only when they were actually present and read.
- `SDD Criteria`: for `SDD: 필요` Milestones, list the SDD path, status, targeted Acceptance Scenario ids, their Milestone Task ids, and the Evidence Map rows that drive the plan. State explicitly how those rows shaped the implementation checklist and final verification. If the selected Milestone has `SDD: 불필요`, state the recorded reason. If the work is not Milestone-linked, state "not applicable".
- `SDD Criteria`: for `SDD: 필요` Milestones, list the SDD path, status, first-line `milestone-task` ids, targeted Acceptance Scenario ids, and the Evidence Map rows that drive the plan. State explicitly how those rows shaped the implementation checklist and final verification. If the selected Milestone has `SDD: 불필요`, state the recorded reason. If the work is not Milestone-linked, state "not applicable".
- `Verification Context`: state whether a handoff was supplied, every source path actually read, concrete commands/criteria applied, preconditions, constraints, gaps, confidence, and repository-native fallback evidence. If required verification leaves the current checkout, include an `External Verification Preflight` record with runner, repo root/workdir, branch/HEAD/dirty state, source sync status, binary/artifact paths, required command help/version output, config path, runtime identity, ports/process state, external hosts, OS/arch assumptions, and the exact setup/sync/rebuild step or blocker derived from mismatches.
- `Test Coverage Gaps`: list each behavior change and whether existing tests cover it; explicitly note gaps.
- `Symbol References`: list renamed/removed symbols and every call site found, or state "none" if no symbols were changed.
@ -264,7 +269,13 @@ Required sections:
- `Final Routing`: record `evaluation_mode`, finalizer, both targets' closure/grade/route, `large_indivisible_context`, positive loop-risk names/count, recovery signals, capability-gap evidence, and canonical filenames. Do not include or compare a previous loop's lane/G.
- `Implementation Checklist`: a top-level checklist the implementing agent must follow while coding. Include one item per implementation/verification unit; if the roadmap feature Task has `검증:`, keep that verification in the same checklist item instead of making a separate completion-criteria item. Include one item for whole-plan intermediate/final verification only when it is not already covered by the feature items. Make the last item exactly `- [ ] Fill implementation-owned sections in CODE_REVIEW-*-G??.md with actual implementation notes and verification output.` Copy this checklist into the review stub's `Implementation Checklist` section with the same item text and order.
- One item per change: `### [TAG-1] Title`, `TAG-2`, etc.
- `Modified Files Summary`: table mapping files to item ids.
- `Modified Files Summary`: table mapping files to item ids. This is the skill-owned implementation write boundary.
- Include exactly one `## Modified Files Summary` section and at least one exact workspace file path.
- Wrap every claimed file path in backticks. A bare path cell is invalid.
- Use repository-relative or canonical absolute file paths. Never use a glob (`*`, `?`, `[]`), directory path, workspace root, URL, path outside the workspace, malformed path, or prose placeholder as a claim.
- Enumerate only implementer- or reviewer-owned workspace files, including the active review evidence file and deterministic workspace evidence artifacts.
- For generated verification artifacts, choose deterministic exact workspace filenames or write them under a task-specific temporary directory outside the repository. Never substitute a directory or glob claim for dynamic filenames.
- Before writing or returning a prepared pair, inspect the rendered PLAN itself and confirm this section occurs exactly once, is non-empty, and satisfies every path rule above. For a follow-up, also confirm every `direct-fix` target in `Finding Resolution Map` is present here.
- `Final Verification`: runnable commands and expected outcome. Prefer commands from verified handoff facts when supplied; fill missing coverage from repository manifests, scripts, workflows, domain rules, and related tests, and record the source in `Analysis > Verification Context`. Commands must be exact and deterministic enough for the reviewer to rerun; use stable ordering for searches and state whether cached test output is acceptable. End this section with **"After completing all code changes, fill implementation-owned sections in `CODE_REVIEW-*-G??.md`."**
Each plan item must include:
@ -310,15 +321,15 @@ Verification fidelity rules:
- `Verification Results` (legacy: `검증 결과`) must contain actual stdout/stderr, not summarized or reconstructed output. If output is too long, record the saved output file path and the exact command used to create it.
- If mobile/UI verification has no progress for 2 minutes or times out, stop blind retries; collect focused stdout plus screenshot/window/UI-tree evidence when available, or record why capture is impossible.
- If the plan's pass condition says all leftovers must be intentional exceptions, any `변경 필요` item forces FAIL until resolved or explicitly reclassified with evidence.
- Decide in the plan whether Go test cache output is acceptable. If fresh execution matters, use `go test -count=1 ...`.
- Decide in the plan whether cached test output is acceptable. If fresh execution matters, use the repository's test runner option that forces a fresh run or bypasses cached results, and record the exact command.
## Step 6 - Write Review Stub
Read `agent-ops/skills/common/plan/templates/review-stub-template.md` in full only after Step 3 returns `status: routed`.
Replace every occurrence of each token below:
- Scalar tokens: `{date}`, `{task_group}`, `{task_name}`, `{plan_number}`, `{TAG}`, `{build_lane}`, `{build_grade}`, `{review_lane}`, `{review_grade}`, `{plan_log_number}`, `{review_log_number}`.
- Plan-copy tokens: `{roadmap_targets_or_omit}`, `{archive_evidence_snapshot_or_omit}`, `{implementation_checklist}`, `{review_checkpoints}`.
- Scalar tokens: `{date}`, `{task_group}`, `{task_name}`, `{plan_number}`, `{TAG}`, `{milestone_task_metadata_or_omit}`, `{build_lane}`, `{build_grade}`, `{review_lane}`, `{review_grade}`, `{plan_log_number}`, `{review_log_number}`. Set `{milestone_task_metadata_or_omit}` to ` milestone-task=<ids>` for `m-*` and to an empty string otherwise.
- Plan-copy tokens: `{archive_evidence_snapshot_or_omit}`, `{implementation_checklist}`, `{review_checkpoints}`.
- Generated row/section tokens: `{implementation_completion_rows}` contains one row for every plan item, and `{verification_result_sections}` contains the fixed verification instructions plus every intermediate/final command from the plan.
Use the routed build/review grades independently. Remove optional plan-copy content by replacing its token with an empty string, not by leaving template instructions.
@ -337,17 +348,18 @@ Do not write or return a prepared pair when either routing target is not `routed
## Final Checklist
- In `write` mode, the routed `PLAN-{build_lane}-GNN.md` and `CODE_REVIEW-{review_lane}-GNN.md` both exist under `agent-task/{task_name}/`. In `prepare-follow-up` mode, neither routed file was written; both exact bodies and basenames were returned while the verdict-appended current pair remained active.
- The plan skill directly checked the rendered PLAN before the pair was written or returned. Its single non-empty `Modified Files Summary` contains only exact workspace file claims and no glob or directory claim.
- In `write` mode, `.gitignore` has the Agent-Ops managed block that unignores `agent-task/**/*.md` and `agent-task/**/*.log` and ignores local `agent-roadmap/current.md`. In `prepare-follow-up` mode, the block was only inspected and any needed repair was returned as `gitignore_repair_needed`.
- Single-plan work stores active files directly under `agent-task/{task_group}/`.
- Split work, if any, uses one shared `agent-task/{task_group}/` parent and one subtask directory per plan/review pair with names like `01_core`, `02+01_edge_integration`, `03+01_node_integration`; dependency details live in the subtask directory name as `NN+PP[,QQ...]_subtask_name`.
- Split work, if any, uses one shared `agent-task/{task_group}/` parent and one subtask directory per plan/review pair with names like `01_core`, `02+01_app_a_integration`, `03+01_app_b_integration`; dependency details live in the subtask directory name as `NN+PP[,QQ...]_subtask_name`.
- Split sibling indices follow topological dependency order: every predecessor is lower than its consumer, and every gap is explained by an unchanged existing predecessor or an occupied active/archive index.
- Milestone-linked work uses `agent-task/m-<milestone-slug>/` as the task group; non-roadmap task groups do not start with `m-`.
- Both first lines match `<!-- task={task_name} plan={plan_number} tag={TAG} -->`.
- Both first lines are identical. Non-milestone pairs match `<!-- task={task_name} plan={plan_number} tag={TAG} -->`; `m-*` pairs append exactly ` milestone-task=<task-id>[,<task-id>...]` before ` -->`.
- The review stub was rendered from `agent-ops/skills/common/plan/templates/review-stub-template.md` after routing and has no unresolved known template token.
- In `write` mode, previous active files, if any, were archived with lane/grade parsed from their own basenames and the correct current archive suffixes. In `prepare-follow-up` mode, those archive names were only predicted.
- In `write` mode when resuming from `USER_REVIEW.md`, it was archived to the calculated `current_user_review_archive_name` and the resolved linked Milestone decision was recorded in the new plan.
- `Roadmap Targets` exists only when PASS should check explicit Milestone Task ids, the task group is `m-<milestone-slug>` for the listed Milestone path, and every listed Task id exists in the selected active Milestone.
- If `Roadmap Targets` exists in the plan, the review stub contains the identical section. If it does not exist in the plan, the review stub omits it too.
- In `write` mode when resuming from `USER_REVIEW.md`, it was archived to the calculated `current_user_review_archive_name` and the resolved user action or decision was recorded in the new plan.
- Every `m-*` pair has a non-empty, duplicate-free `milestone-task` list whose ids exist in the selected active Milestone; non-milestone pairs omit the field.
- `milestone-task` ids describe evidence contribution scope, not check-on-PASS completion. Split/follow-up pairs preserve their declared scope under the refinement and follow-up rules.
- If the selected Milestone has `SDD: 필요`, the plan's `Analysis > SDD Criteria` (legacy: `분석 결과 > SDD 기준`) proves that the implementation checklist and final verification were derived from the approved SDD Acceptance Scenarios and Evidence Map. Missing SDD mapping blocks plan creation.
- If the plan is a follow-up or resumes from prior archive evidence, it has `Archive Evidence Snapshot` and the review stub contains the identical section.
- `Analysis > Verification Context` records supplied handoff facts, source paths actually read, external preflight, gaps, confidence, and repository-native fallback evidence.
@ -356,6 +368,7 @@ Do not write or return a prepared pair when either routing target is not `routed
- The plan and review stub have matching `Implementation Checklist` (legacy: `구현 체크리스트`) item text/order; their final checkbox is the mandatory `CODE_REVIEW-*-G??.md` evidence item.
- `finalize-task-routing` ran once after the PLAN body was complete, used no routing-only evidence pass, counted only positive packet-local risk, kept capability/grade basis from being relabeled by escalation signals, and produced matching filenames.
- Review WARN/FAIL follow-ups entered through this plan skill and did not inherit or compare the archived lane/G.
- Every WARN/FAIL finding has one proven direct fix or ordered/satisfied dependency; only then is ownership closed, and verification does not repeat against an unchanged precondition.
- The plan's implementer instructions and review stub limit local implementation agents to implementation/test/evidence work and keep user-review classification plus control-plane stop files out of their input and ownership.
- The review stub has a clearly marked `Review-Only Checklist` (legacy: `코드리뷰 전용 체크리스트`) owned only by the review agent.
- Routed review file completion table lists every plan item.

View file

@ -1,4 +1,4 @@
<!-- task={task_name} plan={plan_number} tag={TAG} -->
<!-- task={task_name} plan={plan_number} tag={TAG}{milestone_task_metadata_or_omit} -->
# Code Review Reference - {TAG}
@ -6,6 +6,7 @@
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `Implementation Checklist`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> Execute the plan's selected root cause, scope, files, and dependency decisions as written. Do not choose another owner, narrow/expand the write boundary, or replace a fix with another verification attempt.
> If implementation is blocked, record the exact blocker, attempted commands/output, and resume condition only in implementation-owned evidence fields.
> Do not ask the user directly, present choices, call user-input tools, create control-plane stop files, or classify the next state.
> Finalization (`Code Review Result`, log rename, `complete.log`, archive moves, `Review-Only Checklist`) is review-agent-only, even after compaction/resume.
@ -16,7 +17,6 @@
date={date}
task={task_name}, plan={plan_number}, tag={TAG}
{roadmap_targets_or_omit}
{archive_evidence_snapshot_or_omit}
## For the Review Agent
@ -29,7 +29,7 @@ Review completion means the following steps are finished:
1. Append verdict and `review_rework_count` / `evidence_integrity_failure` routing signals.
2. Archive `CODE_REVIEW-{review_lane}-{review_grade}.md``code_review_{review_lane}_{review_grade}_{review_log_number}.log` and `PLAN-{build_lane}-{build_grade}.md``plan_{build_lane}_{build_grade}_{plan_log_number}.log`.
3. If PASS, write `complete.log` and move active task directory to `agent-task/archive/YYYY/MM/{task_name}/`. If WARN/FAIL, fully write the next filesystem state required by the code-review skill.
4. If PASS and task group is `m-<milestone-slug>`, report completion event metadata. Roadmap state check and `update-roadmap` calls are runtime responsibilities.
4. If PASS and task group is `m-<milestone-slug>`, preserve the first-line `milestone-task` metadata in `complete.log` and report it for the runtime aggregation event. Roadmap state evaluation belongs to `sync-milestone-workstate`.
5. Check applicable `Review-Only Checklist` items at the final `.log` location before reporting.
---
@ -56,7 +56,7 @@ Review completion means the following steps are finished:
- [ ] Verify that the Agent-Ops managed block in `.gitignore` unignores `agent-task/**/*.md` and `agent-task/**/*.log` and ignores `agent-roadmap/current.md`.
- [ ] If PASS, write `complete.log` based on `agent-ops/skills/common/code-review/templates/complete-log-template.md` and leave no active `.md` files.
- [ ] If PASS, move active task directory `agent-task/{task_name}/` to `agent-task/archive/YYYY/MM/{task_name}/` and update this checklist at the final archive path.
- [ ] If PASS and task group is `m-<milestone-slug>`, report completion event metadata for runtime, without modifying roadmap or directly calling `update-roadmap`.
- [ ] If PASS and task group is `m-<milestone-slug>`, preserve and report `milestone-task` metadata for runtime aggregation, without modifying roadmap or directly calling `update-roadmap`.
- [ ] If PASS for split work, remove empty active parent `agent-task/{task_group}/` or verify it was kept due to remaining siblings/files.
- [ ] If WARN/FAIL, write the next filesystem state matching code-review verdict and do not write `complete.log`.
@ -87,7 +87,6 @@ _Record key design decisions here._
| Section | Owner | Note |
|---------|-------|------|
| Header comment, Overview, Review Agent Instructions | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) |
| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS |
| Archive Evidence Snapshot | Fixed at stub creation from plan when present | Implementing agent uses it as default prior-loop context; read only the specific archive files cited there when more detail is required |
| Implementation Item Completion (item names) | Fixed at stub creation | Implementing agent checks `[ ]``[x]` only |
| Implementation Checklist (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]``[x]` only |

View file

@ -0,0 +1,129 @@
---
name: prepare-epic-work-items
description: 현재 또는 지정 Milestone의 정확히 한 Epic을 작은 직접 작업과 큰 PLAN/CODE_REVIEW pair로 변환하고, fresh one-shot 자가검토, refine-plans 세분화, 최종 재검토와 push까지 수행할 때 사용한다. "현 마일스톤의 X Epic에서 작은 작업은 바로 처리하고 큰 작업은 plan으로 작성해", "X 마일스톤 Y Epic 작업 준비해" 요청에서 사용한다.
---
# Prepare Epic Work Items
## 목적
정확히 한 Epic을 한 사이클로 준비한다. 작은 작업은 구현·검증하고 큰 작업은 실행 가능한 PLAN/CODE_REVIEW pair로 만든 뒤 두 번의 fresh 검토와 한 번의 세분화를 거쳐 remote branch에 보존한다.
## 입력
- `workspace`: 준비된 feature worktree 절대 경로 (필수)
- `target-milestone`: 활성 Milestone slug 또는 경로 (필수)
- `target-epic`: 정확한 Epic id 또는 이름 (필수)
- `execution-catalog`: 런타임이 주입한 agent-model 실행 카탈로그 경로. `AGENT_TASK_EXECUTION_CATALOG`로 대신 주입할 수 있다. (필수)
- `planner-target`: 카탈로그에 선언된 materialize/refine 실행 target id. `AGENT_TASK_PLANNER_TARGET`로 대신 주입할 수 있다. (필수)
- `review-target`: 카탈로그에 선언된 initial/final review target id. `AGENT_TASK_REVIEW_TARGET`로 주입하거나 생략하면 `planner-target`과 같다. (선택)
- `retry`: terminal failure의 원인을 사용자가 해소한 뒤 같은 Epic 상태를 재개할 때만 사용한다. (선택)
- `batch-task-ids`: 상위 `prepare-milestone-workspace`가 고정한 선택 Epic Task id 합집합. 직접 호출에서는 사용하지 않는다. (내부 선택)
## 범위 계약
- 한 실행은 Epic 하나만 다룬다. Epic 범위 요청은 `prepare-milestone-workspace` coordinator가 문서 순서대로 하나씩 실행한다.
- 실행 identity는 `<milestone-slug>:<epic-id>`다.
- 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을 만들지 않는다.
작은 작업은 아래를 모두 만족해야 한다.
- 하나의 응집된 변경이고 한 번의 bounded 실행과 명시 검증으로 완료할 수 있다.
- 새 API, wire, schema, migration, 외부 side effect 또는 책임 경계 변경이 없다.
- 사용자·SDD 결정이 필요하지 않고 큰 작업의 write set과 충돌하지 않는다.
하나라도 거짓이거나 불명확하면 큰 작업으로 분류한다. 고정 LOC나 파일 수만으로 분류하지 않는다.
## 실행 절차
1. **Epic을 고정한다**
- target Milestone이 `[계획]` 또는 `[진행중]`, 구현 잠금 해제인지 확인한다.
- `### Epic: [<epic-id>] <title>`을 정확히 하나 찾고 그 아래 Task id를 고정한다.
- standalone에서 다른 Epic의 active pair가 있거나 target이 모호하면 `FAILED`로 멈춘다. 상위 batch에서는 선택 Task id 합집합 밖 pair 또는 Epic 경계를 가로지르는 pair만 거부한다.
2. **foreground 사이클을 실행한다**
- 아래 스크립트를 한 번 실행하고 execution-layer event wait를 유지한다.
```bash
python3 agent-ops/skills/common/prepare-epic-work-items/scripts/run_epic_cycle.py \
--workspace "$WORKSPACE" \
--milestone "$MILESTONE" \
--epic "$EPIC" \
--execution-catalog "$EXECUTION_CATALOG" \
--planner-target "$PLANNER_TARGET" \
--review-target "$REVIEW_TARGET"
```
- agent, model, 실행 명령과 provider별 옵션은 스킬이나 스크립트에 고정하지 않고 카탈로그 target의 opaque metadata와 argv template에서 가져온다.
- target id와 카탈로그 revision은 실행 evidence에 보존한다. `--retry`는 동일 카탈로그 계약과 target을 사용한다.
- 상위 batch에서 호출할 때만 고정된 Task id 합집합을 `--batch-task-ids`로 전달한다.
- 스크립트는 카탈로그가 지시한 각 target을 새 one-shot session으로 실행한다.
- model stdout/stderr는 git common dir의 locator log에만 저장한다. caller stdout에는 lifecycle/attention event만 출력한다.
3. **상태 전이를 따른다**
- `MATERIALIZE`: 작은 작업을 먼저 구현·검증하고, 변경된 source를 기준으로 큰 작업에 `plan`을 적용한다.
- `INITIAL_REVIEW`: fresh reviewer가 전체 변경과 PLAN/CODE_REVIEW stub을 재검토하고 누락을 수정한다. 구현 전 stub에 공식 `code-review`를 실행하지 않는다.
- 첫 검토가 유효하면 변경을 commit/push한다.
- `REFINE`: target Epic Task id를 가진 모든 미착수 pair에 `refine-plans`를 한 번 적용한다. 분리 가치가 없으면 `no-change`를 허용한다.
- `FINAL_REVIEW`: fresh reviewer가 child scope 합집합, 중복, dependency, `milestone-task`, routing, 검증을 다시 확인하고 수정한다.
- 최종 validator가 통과하면 남은 변경을 commit/push하고 `EPIC_WORK_ITEMS_READY`를 낸다.
4. **중단 상태를 처리한다**
- 사용자만 결정할 범위·설계 문제는 roadmap SDD `USER_REVIEW.md`로 남기고 유효한 stop artifact를 commit/push한 뒤 `USER_REVIEW`로 끝낸다.
- agent-task 구현 review gate가 아니므로 preparation agent가 `agent-task/**/USER_REVIEW.md`를 만들지 않는다.
- agent exit, invalid pair, plan validator, git commit/push 실패는 자동 삭제 없이 `FAILED`로 끝낸다.
- foreground wait가 끊겼지만 동일 PID/start-token의 one-shot이 살아 있으면 state를 `tracking`으로 유지하고 `AGENT_TRACKING`만 낸다. 재호출은 새 agent를 만들지 않는다.
- tracking handle이 종료되면 `AGENT_RECOVERY_REQUIRED`에서 멈춘다. locator 확인 뒤 `--retry`하면 현재 artifact를 먼저 채택·검증한다. 검증 실패 뒤의 명시적 `--retry`만 새 one-shot을 허용한다.
- `EPIC_WORK_ITEMS_READY` 뒤 dispatcher와 workstate sync가 Task와 active pair를 모두 닫으면 같은 identity 재호출이 clean HEAD를 재검증해 `EPIC_COMPLETED`로 승격한다.
## 상태 이벤트
- `EPIC_SCOPE_RESOLVED`
- `MATERIALIZE_STARTED`, `MATERIALIZE_FINISHED`
- `INITIAL_REVIEW_STARTED`, `INITIAL_REVIEW_FINISHED`
- `INITIAL_CHECKPOINT_PUSHED`
- `REFINE_STARTED`, `REFINE_FINISHED`
- `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에서만 깨어난다.
## 실행 결과 검증
- [ ] 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가 추적되지 않는가
- [ ] 완료 checkpoint가 현재 feature branch remote에 push됐는가
- 검증 실패 시: partial artifact를 commit하지 않고 locator와 복구 조건을 남겨 `FAILED`로 끝낸다.
## 출력 형식
```text
Epic work preparation
- identity: <milestone-slug>:<epic-id>
- direct work: <completed task ids 또는 없음>
- plans: <active pair paths 또는 없음>
- refinement: <split | no-change>
- event: <EPIC_WORK_ITEMS_READY | EPIC_COMPLETED | USER_REVIEW | FAILED>
- remote: <branch와 pushed commit>
```
## 금지 사항
- 여러 Epic을 한 agent context에서 처리하지 않는다.
- 같은 session을 self-review에 resume하지 않는다.
- plan/refine agent가 nested agent나 task dispatcher를 실행하지 않는다.
- 구현 전 CODE_REVIEW stub에 공식 code-review verdict를 쓰지 않는다.
- timer polling, LLM keepalive, routine model stream 중계를 하지 않는다.
- validation 실패 상태를 commit/push하거나 force push하지 않는다.

View file

@ -0,0 +1,4 @@
interface:
display_name: "Prepare Epic Work Items"
short_description: "Turn one Epic into reviewed work and plans"
default_prompt: "Use $prepare-epic-work-items to complete small work and prepare reviewed plans for one Epic."

View file

@ -0,0 +1,335 @@
#!/usr/bin/env python3
"""Run one fresh target from a runtime-injected execution catalog."""
from __future__ import annotations
import argparse
from datetime import datetime, timezone
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
from typing import Any, Iterable
import uuid
CATALOG_ENV = "AGENT_TASK_EXECUTION_CATALOG"
LABEL_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
PROBE_EXPECTED = "MILESTONE_AGENT_READY"
class AgentRunError(RuntimeError):
"""One-shot runner contract error."""
def load_policy_module():
path = (
Path(__file__).resolve().parents[2]
/ "orchestrate-agent-task-loop"
/ "scripts"
/ "execution_target_policy.py"
)
spec = importlib.util.spec_from_file_location("epic_execution_target_policy", path)
if spec is None or spec.loader is None:
raise AgentRunError(f"execution catalog policy not found: {path}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def now() -> str:
return datetime.now(timezone.utc).isoformat()
def emit(event: str, **payload: Any) -> None:
print(json.dumps({"event": event, **payload}, ensure_ascii=False, sort_keys=True), flush=True)
def atomic_json(path: Path, value: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + f".tmp.{os.getpid()}")
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
os.replace(temporary, path)
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 workspace_root(raw: str) -> Path:
workspace = Path(raw).expanduser().resolve()
if not workspace.is_dir():
raise AgentRunError(f"workspace directory not found: {workspace}")
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
cwd=workspace,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if result.returncode != 0 or Path(result.stdout.strip()).resolve() != workspace:
raise AgentRunError(f"workspace must be a git repository root: {workspace}")
return workspace
def state_root(workspace: Path) -> Path:
result = subprocess.run(
["git", "rev-parse", "--git-common-dir"],
cwd=workspace,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if result.returncode == 0:
raw = Path(result.stdout.strip())
common = (workspace / raw).resolve() if not raw.is_absolute() else raw.resolve()
if os.access(common, os.W_OK):
return common / "epic-work-preparation"
fallback = Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local" / "state")))
identity = hashlib.sha256(str(workspace).encode()).hexdigest()[:16]
return fallback / "epic-work-preparation" / identity
def result_file(workspace: Path, raw: str | None) -> Path | None:
if raw is None:
return None
path = Path(raw).expanduser().resolve()
root = state_root(workspace).resolve()
try:
path.relative_to(root)
except ValueError as exc:
raise AgentRunError(f"--result-file must be inside runner state root: {root}") from exc
return path
def persist(locator: Path, result: Path | None, record: dict[str, Any]) -> None:
atomic_json(locator, record)
if result is not None:
atomic_json(result, record)
def prompt_text(args: argparse.Namespace) -> str:
if args.probe:
return (
"Reply only with the result of joining MILESTONE, _AGENT, and _READY. "
"Do not inspect files, call tools, or modify the workspace."
)
if args.prompt is not None:
return args.prompt
if args.prompt_file is None:
raise AgentRunError("--prompt or --prompt-file is required")
path = Path(args.prompt_file).expanduser().resolve()
if not path.is_file():
raise AgentRunError(f"prompt file not found: {path}")
return path.read_text(encoding="utf-8")
def resolve_target(catalog_path: str, target_id: str):
policy = load_policy_module()
try:
catalog = policy.load_catalog(catalog_path)
except (OSError, ValueError) as exc:
raise AgentRunError(f"invalid execution catalog: {exc}") from exc
target = policy.canonical_target(catalog, target_id)
if target is None:
raise AgentRunError(f"execution catalog target not found: {target_id}")
return catalog, target
def template_values(*, target, prompt: str, workspace: Path, session_id: str, attempt_dir: Path) -> dict[str, str]:
return {
"agent": target.agent,
"attempt_dir": str(attempt_dir),
"model": target.model,
"prompt": prompt,
"resume_session": "",
"session_id": session_id,
"target_id": target.catalog_id,
"workspace": str(workspace),
}
def build_command(*, target, prompt: str, workspace: Path, session_id: str, attempt_dir: Path) -> list[str]:
values = template_values(
target=target,
prompt=prompt,
workspace=workspace,
session_id=session_id,
attempt_dir=attempt_dir,
)
return [str(item).format_map(values) for item in target.runtime["command"]]
def sanitized_command(command: list[str], prompt: str) -> list[str]:
return ["<prompt>" if value == prompt else value for value in command]
def parser() -> argparse.ArgumentParser:
value = argparse.ArgumentParser(description=__doc__)
value.add_argument("--execution-catalog", default=os.environ.get(CATALOG_ENV))
value.add_argument("--target-id", required=True)
value.add_argument("--workspace", required=True)
prompt_group = value.add_mutually_exclusive_group()
prompt_group.add_argument("--prompt")
prompt_group.add_argument("--prompt-file")
value.add_argument("--label", default="one-shot")
value.add_argument("--probe", action="store_true")
value.add_argument("--result-file")
return value
def execute(args: argparse.Namespace) -> int:
workspace = workspace_root(args.workspace)
if not args.execution_catalog:
raise AgentRunError(
f"--execution-catalog or {CATALOG_ENV} is required"
)
catalog, target = resolve_target(args.execution_catalog, args.target_id)
if not LABEL_PATTERN.fullmatch(args.label):
raise AgentRunError("--label may contain only letters, digits, dot, underscore, and hyphen")
prompt = prompt_text(args)
result = result_file(workspace, args.result_file)
executable = target.runtime["command"][0]
if shutil.which(executable) is None:
raise AgentRunError(
f"target command not found: target_id={target.catalog_id} command={executable}"
)
execution_id = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:12]}"
root = state_root(workspace)
attempt_dir = root / "runs" / f"{args.label}-{execution_id}"
attempt_dir.mkdir(parents=True, exist_ok=False)
stream = attempt_dir / "stream.log"
locator = attempt_dir / "locator.json"
session_id = str(uuid.uuid4())
values = template_values(
target=target,
prompt=prompt,
workspace=workspace,
session_id=session_id,
attempt_dir=attempt_dir,
)
command = build_command(
target=target,
prompt=prompt,
workspace=workspace,
session_id=session_id,
attempt_dir=attempt_dir,
)
environment = {
str(key): str(item).format_map(values)
for key, item in target.runtime.get("environment", {}).items()
}
record: dict[str, Any] = {
"execution_id": execution_id,
"label": args.label,
"workspace": str(workspace),
"catalog": {
"source": str(catalog.source),
"revision": catalog.revision,
"schema_version": "1.0",
},
"target_id": target.catalog_id,
"agent": target.agent,
"model": target.model,
"command": sanitized_command(command, prompt),
"prompt_sha256": hashlib.sha256(prompt.encode()).hexdigest(),
"session_id": session_id,
"stream_log": str(stream),
"locator": str(locator),
"started_at": now(),
"status": "starting",
}
persist(locator, result, record)
emit(
"AGENT_STARTED",
agent=target.agent,
execution_id=execution_id,
label=args.label,
locator=str(locator),
model=target.model,
target_id=target.catalog_id,
)
with stream.open("wb") as output:
try:
process = subprocess.Popen(
command,
cwd=workspace,
env={
**os.environ,
"MILESTONE_PREPARATION_EXECUTION_ID": execution_id,
**environment,
},
stdout=output,
stderr=subprocess.STDOUT,
start_new_session=True,
)
except OSError as exc:
record.update(status="failed", finished_at=now(), exit_code=127, error=str(exc))
persist(locator, result, record)
emit("AGENT_FINISHED", execution_id=execution_id, label=args.label, result="failed", exit_code=127)
return 127
record.update(
status="running",
agent_pid=process.pid,
agent_process_start_token=process_start_token(process.pid),
)
persist(locator, result, record)
try:
exit_code = process.wait()
except KeyboardInterrupt:
record.update(status="tracking", interrupted_at=now(), agent_pid=process.pid)
persist(locator, result, record)
emit(
"AGENT_TRACKING",
execution_id=execution_id,
label=args.label,
locator=str(locator),
pid=process.pid,
)
return 3
size = stream.stat().st_size
status = "succeeded" if exit_code == 0 and size > 0 else "failed"
if args.probe and status == "succeeded":
content = stream.read_text(encoding="utf-8", errors="replace")
if PROBE_EXPECTED not in content.upper():
status = "failed"
exit_code = 2
record.update(status=status, finished_at=now(), exit_code=exit_code, output_bytes=size)
persist(locator, result, record)
emit(
"AGENT_FINISHED",
execution_id=execution_id,
exit_code=exit_code,
label=args.label,
locator=str(locator),
result=status,
)
return exit_code if status == "succeeded" else (exit_code or 2)
def main(argv: Iterable[str] | None = None) -> int:
args = parser().parse_args(argv)
try:
return execute(args)
except (AgentRunError, OSError, ValueError) as exc:
emit("AGENT_FINISHED", label=getattr(args, "label", "one-shot"), result="failed", reason=str(exc))
return 2
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,930 @@
#!/usr/bin/env python3
"""Run the event-driven preparation cycle for exactly one Milestone Epic."""
from __future__ import annotations
import argparse
from dataclasses import dataclass
import fcntl
import json
import os
from pathlib import Path
import re
import subprocess
import sys
from typing import Any, Iterable
STAGES = ("materialize", "initial-review", "refine", "final-review")
PLAN_PATTERN = "PLAN-*-G??.md"
REVIEW_PATTERN = "CODE_REVIEW-*-G??.md"
HEADER = re.compile(r"^<!--\s+(?P<body>.*?)\s+-->$")
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>.+)$")
SENSITIVE_PARTS = {".env", "secret", "secrets", "credential", "credentials", "password", "passwords"}
SENSITIVE_SUFFIXES = {".pem", ".key", ".p12"}
class CycleError(RuntimeError):
"""A fail-closed Epic cycle error."""
class TrackingRequired(CycleError):
"""A one-shot agent is still owned by an existing execution handle."""
def __init__(self, stage: str, result: dict[str, Any]) -> None:
self.stage = stage
self.result = result
super().__init__(f"agent execution is still running: stage={stage}")
class TrackingRecoveryRequired(TrackingRequired):
"""A detached execution ended and its artifacts need explicit adoption."""
@dataclass(frozen=True)
class Epic:
epic_id: str
title: str
task_ids: tuple[str, ...]
incomplete_ids: tuple[str, ...]
body: str
def emit(event: str, **payload: Any) -> None:
print(json.dumps({"event": event, **payload}, ensure_ascii=False, sort_keys=True), flush=True)
def run(
command: list[str],
*,
cwd: Path,
check: bool = True,
capture: bool = True,
) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
command,
cwd=cwd,
text=True,
stdout=subprocess.PIPE if capture else None,
stderr=subprocess.PIPE if capture else None,
check=False,
)
if check and result.returncode != 0:
detail = (result.stderr or result.stdout or "").strip()
raise CycleError(f"command failed ({result.returncode}): {' '.join(command)}: {detail}")
return result
def git(workspace: Path, *arguments: str, check: bool = True) -> str:
result = run(["git", *arguments], cwd=workspace, check=check)
return (result.stdout or "").strip()
def resolve_workspace(raw: str) -> Path:
workspace = Path(raw).expanduser().resolve()
if not workspace.is_dir():
raise CycleError(f"workspace not found: {workspace}")
top = Path(git(workspace, "rev-parse", "--show-toplevel")).resolve()
if top != workspace:
raise CycleError(f"workspace must be git root: expected={top} actual={workspace}")
branch = git(workspace, "branch", "--show-current")
if not branch:
raise CycleError("detached HEAD is not supported")
develop = git(workspace, "config", "--get", "gitflow.branch.develop")
feature_prefix = git(workspace, "config", "--get", "gitflow.prefix.feature")
if not develop or not feature_prefix or not branch.startswith(feature_prefix):
raise CycleError(f"Epic preparation requires a Git Flow feature branch: actual={branch}")
return workspace
def resolve_milestone(workspace: Path, raw: str) -> tuple[Path, re.Match[str]]:
candidate = Path(raw).expanduser()
path = (workspace / candidate).resolve() if not candidate.is_absolute() else candidate.resolve()
try:
relative = path.relative_to(workspace).as_posix()
except ValueError as exc:
raise CycleError(f"milestone outside workspace: {path}") from exc
match = MILESTONE_PATTERN.fullmatch(relative)
if match is None or not path.is_file():
raise CycleError(f"active milestone path required: {relative}")
return path, match
def section(text: str, heading: str) -> str:
match = re.search(
rf"^## {re.escape(heading)}\s*$\n(?P<body>.*?)(?=^##\s|\Z)",
text,
re.MULTILINE | re.DOTALL,
)
return match.group("body").strip() if match else ""
def verify_milestone_gate(text: str) -> None:
status_body = section(text, "상태")
status_match = re.search(r"^\[(.+?)\]\s*$", status_body, re.MULTILINE)
status = status_match.group(1).strip() if status_match else ""
if status not in {"계획", "진행중"}:
raise CycleError(f"milestone must be [계획] or [진행중]: actual={status or 'missing'}")
lock = section(text, "구현 잠금")
if not re.search(r"^- 상태:\s*해제\s*$", lock, re.MULTILINE):
raise CycleError("milestone implementation lock is not 해제")
if not re.search(r"^- 결정 필요:\s*없음\s*$", lock, re.MULTILINE):
raise CycleError("milestone has unresolved 결정 필요")
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)
body_lines = lines[start + 1 : end]
tasks = [TASK_LINE.fullmatch(line) for line in body_lines]
task_matches = [value for value in tasks 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") == " "
),
body="\n".join(body_lines).strip(),
)
)
return epics
def select_epic(epics: list[Epic], selector: str) -> Epic:
exact_id = [epic for epic in epics if epic.epic_id == selector]
if len(exact_id) == 1:
return exact_id[0]
normalized = selector.casefold().strip()
exact_title = [epic for epic in epics if epic.title.casefold().strip() == normalized]
if len(exact_title) == 1:
return exact_title[0]
raise CycleError(f"target Epic must resolve exactly once: selector={selector}")
def git_common_dir(workspace: Path) -> Path:
raw = Path(git(workspace, "rev-parse", "--git-common-dir"))
return (workspace / raw).resolve() if not raw.is_absolute() else raw.resolve()
def atomic_json(path: Path, value: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + f".tmp.{os.getpid()}")
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
os.replace(temporary, path)
def read_state(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 CycleError(f"invalid cycle state: {path}: {exc}") from exc
if not isinstance(value, dict):
raise CycleError(f"cycle state must be 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 stage_result(path: Path, *, workspace: Path, label: str) -> dict[str, Any] | None:
value = read_state(path)
if value is None:
return None
if value.get("workspace") != str(workspace) or value.get("label") != label:
raise CycleError(f"agent result identity mismatch: {path}")
locator = value.get("locator")
if not isinstance(locator, str) or not Path(locator).is_file():
raise CycleError(f"agent result locator is missing: {path}")
return value
def changed_paths(workspace: Path) -> list[str]:
unmerged = git(workspace, "diff", "--name-only", "--diff-filter=U")
if unmerged:
raise CycleError(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 CycleError(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 parse_header(path: Path) -> dict[str, str]:
try:
first = path.read_text(encoding="utf-8").splitlines()[0]
except (OSError, IndexError) as exc:
raise CycleError(f"missing first-line metadata: {path}") from exc
match = HEADER.fullmatch(first)
if not match:
raise CycleError(f"invalid first-line metadata: {path}")
values: dict[str, str] = {}
for token in match.group("body").split():
key, separator, value = token.partition("=")
if separator:
values[key] = value
required = {"task", "plan", "tag", "milestone-task"}
missing = sorted(required - values.keys())
if missing:
raise CycleError(f"metadata fields missing in {path}: {','.join(missing)}")
return values
def active_pairs(workspace: Path, task_group: str) -> list[tuple[Path, Path, dict[str, str]]]:
root = workspace / "agent-task" / task_group
if not root.exists():
return []
directories = [root, *sorted(path for path in root.iterdir() if path.is_dir())]
pairs: list[tuple[Path, Path, dict[str, str]]] = []
for directory in directories:
plans = sorted(directory.glob(PLAN_PATTERN))
reviews = sorted(directory.glob(REVIEW_PATTERN))
if not plans and not reviews:
continue
if len(plans) != 1 or len(reviews) != 1:
raise CycleError(f"active PLAN/CODE_REVIEW pair required: {directory}")
plan_header = parse_header(plans[0])
review_header = parse_header(reviews[0])
if plan_header != review_header:
raise CycleError(f"PLAN/CODE_REVIEW metadata mismatch: {directory}")
if plan_header["task"].split("/", 1)[0] != task_group:
raise CycleError(f"task group mismatch: {plans[0]}")
pairs.append((plans[0], reviews[0], plan_header))
return pairs
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]]:
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_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}")
pair_ids = set(ids)
outside = sorted(pair_ids - allowed)
if outside:
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}")
if dispatcher.is_file():
run(
[sys.executable, str(dispatcher), "--workspace", str(workspace), "--validate-plan", str(plan)],
cwd=workspace,
)
git(workspace, "diff", "--check")
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():
return []
return sorted(root.glob("USER_REVIEW.md")) + sorted(root.glob("*/USER_REVIEW.md"))
def sdd_user_review(workspace: Path, phase_slug: str, milestone_slug: str) -> Path:
return workspace / "agent-roadmap" / "sdd" / phase_slug / milestone_slug / "USER_REVIEW.md"
def stage_prompt(
*,
stage: str,
workspace: Path,
milestone: Path,
epic: Epic,
task_group: str,
base_head: str,
checkpoint_head: str | None,
) -> str:
common = f"""You are a fresh child agent launched for one bounded Epic preparation stage, not the caller or monitor.
Work only in {workspace}.
Read the repository AGENTS.md completely, then read agent-ops/skills/common/router.md and only the skills required for this stage.
Do not start subagents, orchestration dispatchers, prepare-milestone-workspace, prepare-epic-work-items, or any monitoring loop.
Do not commit or push; the parent runtime owns Git checkpoints.
Target Milestone: {milestone}
Target Epic: [{epic.epic_id}] {epic.title}
Allowed Milestone Task ids: {','.join(epic.task_ids)}
Active task group: agent-task/{task_group}
Keep every change inside this Epic and preserve user changes. Final in Korean.
"""
if stage == "materialize":
return common + f"""
Materialize this Epic once. Read current source, tests, SDD, matching spec and contracts required by AGENTS.md.
Classify cohesive slices as direct-small only when each is one bounded change, has explicit verification, changes no API/wire/schema/migration/external side effect/responsibility boundary, needs no user decision, and does not collide with planned work. Treat every uncertain slice as large.
Implement and verify all direct-small slices first. Then, against that updated source, use agent-ops/skills/common/plan/SKILL.md in write mode to create valid PLAN/CODE_REVIEW pairs for every remaining large slice. Preserve exact milestone-task ids and let plan perform final routing. Do not use official code-review on unimplemented stubs.
If a genuine product/scope decision is required, use roadmap-sdd review-ready for this Milestone and stop without inventing a decision.
Starting HEAD: {base_head}
"""
if stage == "initial-review":
return common + f"""
Review everything produced for this Epic since {base_head}, including direct code/test/document changes and every active PLAN/CODE_REVIEW stub. This is the explicit self-review request: review the work and fix every material omission you find.
Re-run appropriate verification for direct-small work. For semantic plan defects, use the plan skill's explicit write/replan path so routing and paired files remain valid. Do not append an official code-review verdict.
Run sync-milestone-workstate mode=sync only for exact Task ids whose direct work is fully implemented and evidenced; never complete an id that still has pending plan scope.
If no material defect exists, leave correct artifacts unchanged.
"""
if stage == "refine":
return common + """
Read agent-ops/skills/common/refine-plans/SKILL.md and apply it once to every eligible unstarted active pair in the target task group whose milestone-task ids belong to this Epic. Preserve original scope and do not re-read source/tests or run verification. A justified no-change decision is valid. Do not recursively split a child created in this pass.
"""
if stage == "final-review":
return common + f"""
Review the refined active pair set for this Epic from a fresh context. Compare the child scope union, milestone-task union, write sets, verification, dependencies, indices, PLAN/CODE_REVIEW metadata, and routing against the Milestone, SDD, current source, and the pre-refine intent at checkpoint {checkpoint_head or base_head}. Fix every material defect using the owning plan/refine procedure; do not append an official code-review verdict. If a semantic replan replaces a pair, apply refine-plans once to that replacement when it remains eligible. Finish only with valid unstarted pairs or no pairs when all Epic work was direct-small.
"""
raise CycleError(f"unsupported stage: {stage}")
def run_agent_stage(
*,
workspace: Path,
state_root: Path,
identity: str,
stage: str,
prompt: str,
execution_catalog: str,
target_id: str,
prior_cycle_status: str,
retry: bool,
) -> Path:
runner = Path(__file__).resolve().with_name("run_agent_once.py")
prompt_path = state_root / "prompts" / f"{stage}.txt"
result_path = state_root / "attempts" / f"{stage}.json"
prompt_path.parent.mkdir(parents=True, exist_ok=True)
prompt_path.write_text(prompt, encoding="utf-8")
label = f"{identity}-{stage}"
previous = stage_result(result_path, workspace=workspace, label=label)
if previous is not None:
previous_status = previous.get("status")
is_live = previous_status in {"running", "tracking"} and process_is_same(
previous.get("agent_pid"), previous.get("agent_process_start_token")
)
if is_live:
raise TrackingRequired(stage, previous)
if previous_status == "succeeded" and not (
retry and prior_cycle_status == "failed"
):
emit(
"AGENT_RESULT_RECOVERED",
stage=stage,
locator=previous["locator"],
result="succeeded",
)
return result_path
if previous_status in {"running", "tracking"} and prior_cycle_status in {
"running",
"tracking",
}:
if not retry:
raise TrackingRecoveryRequired(stage, previous)
emit(
"AGENT_RESULT_RECOVERED",
stage=stage,
locator=previous["locator"],
result="detached-artifacts",
)
return result_path
if not retry:
raise CycleError(
f"prior agent result requires --retry: stage={stage} status={previous_status}"
)
command = [
sys.executable,
str(runner),
"--execution-catalog",
execution_catalog,
"--target-id",
target_id,
"--workspace",
str(workspace),
"--prompt-file",
str(prompt_path),
"--label",
label,
"--result-file",
str(result_path),
]
result = run(command, cwd=workspace, check=False, capture=False)
if result.returncode != 0:
if result.returncode == 3:
tracked = stage_result(result_path, workspace=workspace, label=label)
if tracked is None:
raise CycleError(f"agent tracking result missing: stage={stage}")
raise TrackingRequired(stage, tracked)
raise CycleError(f"agent stage failed: stage={stage} exit={result.returncode}")
final = stage_result(result_path, workspace=workspace, label=label)
if final is None or final.get("status") != "succeeded":
raise CycleError(f"agent stage returned without succeeded result: stage={stage}")
return result_path
def publish(workspace: Path, epic: Epic, phase: str) -> str:
paths = changed_paths(workspace)
if paths:
unsafe = [path for path in paths if sensitive(path)]
if unsafe:
raise CycleError(f"sensitive path refused: {','.join(unsafe)}")
for path in paths:
git(workspace, "add", "--", path)
git(workspace, "diff", "--cached", "--check")
message = (
f"feat(epic): {epic.epic_id} 작업을 준비한다"
if phase == "initial"
else f"chore(epic): {epic.epic_id} 준비 결과를 검증한다"
)
git(workspace, "commit", "-m", message)
branch = git(workspace, "branch", "--show-current")
upstream = git(workspace, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
if not upstream:
raise CycleError(f"feature branch has no upstream: {branch}")
if not upstream.endswith(f"/{branch}"):
raise CycleError(f"feature branch upstream mismatch: branch={branch} upstream={upstream}")
git(workspace, "push")
if changed_paths(workspace):
raise CycleError("workspace is dirty after publish")
return git(workspace, "rev-parse", "HEAD")
def parser() -> argparse.ArgumentParser:
value = argparse.ArgumentParser(description=__doc__)
value.add_argument("--workspace", required=True)
value.add_argument("--milestone", required=True)
value.add_argument("--epic", required=True)
value.add_argument(
"--execution-catalog",
default=os.environ.get("AGENT_TASK_EXECUTION_CATALOG"),
)
value.add_argument(
"--planner-target", default=os.environ.get("AGENT_TASK_PLANNER_TARGET")
)
value.add_argument(
"--review-target", default=os.environ.get("AGENT_TASK_REVIEW_TARGET")
)
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
def apply_defaults(args: argparse.Namespace) -> argparse.Namespace:
if not args.execution_catalog:
raise CycleError(
"--execution-catalog or AGENT_TASK_EXECUTION_CATALOG is required"
)
if not args.planner_target:
raise CycleError(
"--planner-target or AGENT_TASK_PLANNER_TARGET is required"
)
if args.review_target is None:
args.review_target = args.planner_target
return args
def cycle(args: argparse.Namespace) -> int:
apply_defaults(args)
workspace = resolve_workspace(args.workspace)
milestone_path, milestone_match = resolve_milestone(workspace, args.milestone)
milestone_text = milestone_path.read_text(encoding="utf-8")
verify_milestone_gate(milestone_text)
epic = select_epic(parse_epics(milestone_text), args.epic)
milestone_slug = milestone_match.group("slug")
phase_slug = milestone_match.group("phase")
task_group = f"m-{milestone_slug}"
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}"
if branch != expected_branch:
raise CycleError(f"workspace branch does not match Milestone slug: expected={expected_branch} actual={branch}")
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 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)
with (state_root / "cycle.lock").open("a+", encoding="utf-8") as lock:
try:
fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
raise CycleError(f"Epic cycle already running: {identity}") from exc
state = read_state(state_path)
current_head = git(workspace, "rev-parse", "HEAD")
if state and state.get("identity") != identity:
raise CycleError(f"cycle state identity mismatch: {state_path}")
if (
state
and state.get("task_ids") is not None
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("status") != "completed"
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),
allowed_task_ids,
)
completed_with_plan = sorted(task_union - set(refreshed.incomplete_ids))
if completed_with_plan:
raise CycleError(
"completed Task ids still have active plans: " + ",".join(completed_with_plan)
)
remaining_without_plan = sorted(set(refreshed.incomplete_ids) - task_union)
if remaining_without_plan:
raise CycleError(
"completed cycle no longer has evidence for incomplete Task ids; run workstate sync or recover plans: "
+ ",".join(remaining_without_plan)
)
terminal = "EPIC_COMPLETED" if not refreshed.incomplete_ids else "EPIC_WORK_ITEMS_READY"
state.update(event=terminal, head=current_head)
atomic_json(state_path, state)
emit(terminal, identity=identity, resumed=True, head=current_head, plans=len(pairs))
return 0
review_path = sdd_user_review(workspace, phase_slug, milestone_slug)
if state and state.get("status") == "user-review" and review_path.exists():
emit("USER_REVIEW", identity=identity, path=str(review_path), resumed=True)
return 2
prior_cycle_status = str(state.get("status")) if state else "new"
if state and state.get("status") == "failed" and not args.retry:
raise CycleError(f"prior terminal failure requires --retry: {state.get('reason', 'unknown')}")
if state is None:
if changed_paths(workspace):
raise CycleError("clean feature workspace required before a new Epic cycle")
if review_path.exists():
emit("USER_REVIEW", identity=identity, path=str(review_path))
return 2
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),
allowed_task_ids,
)
if existing:
raise CycleError(
"completed Epic still has active PLAN/CODE_REVIEW pairs; reconcile them before completion"
)
state = {
"identity": identity,
"task_ids": list(epic.task_ids),
"batch_task_ids": sorted(allowed_task_ids),
"status": "completed",
"event": "EPIC_COMPLETED",
"head": current_head,
}
atomic_json(state_path, state)
emit("EPIC_COMPLETED", identity=identity)
return 0
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,
"checkpoint_head": None,
"pre_refine_ids": [],
}
atomic_json(state_path, state)
emit(
"EPIC_SCOPE_RESOLVED",
identity=identity,
task_ids=list(epic.task_ids),
incomplete_ids=list(epic.incomplete_ids),
)
elif changed_paths(workspace) and not args.retry:
raise CycleError("dirty recovery state requires explicit --retry")
reviewer_target = args.review_target or args.planner_target
start_index = STAGES.index(str(state.get("next_stage", STAGES[0])))
for stage in STAGES[start_index:]:
stage_head = git(workspace, "rev-parse", "HEAD")
event_prefix = stage.upper().replace("-", "_")
emit(f"{event_prefix}_STARTED", identity=identity)
target_id = (
args.planner_target
if stage in {"materialize", "refine"}
else reviewer_target
)
prompt = stage_prompt(
stage=stage,
workspace=workspace,
milestone=milestone_path,
epic=epic,
task_group=task_group,
base_head=str(state["base_head"]),
checkpoint_head=state.get("checkpoint_head"),
)
result_path = state_root / "attempts" / f"{stage}.json"
state.update(
status="running",
current_stage=stage,
active_result=str(result_path),
)
atomic_json(state_path, state)
try:
run_agent_stage(
workspace=workspace,
state_root=state_root,
identity=identity.replace(":", "-"),
stage=stage,
prompt=prompt,
execution_catalog=args.execution_catalog,
target_id=target_id,
prior_cycle_status=prior_cycle_status,
retry=args.retry,
)
except TrackingRequired as exc:
state.update(
status="tracking",
current_stage=stage,
active_result=str(result_path),
locator=exc.result.get("locator"),
)
atomic_json(state_path, state)
if isinstance(exc, TrackingRecoveryRequired):
state["recovery_required"] = True
atomic_json(state_path, state)
emit(
"AGENT_RECOVERY_REQUIRED",
identity=identity,
stage=stage,
locator=exc.result.get("locator"),
action="inspect locator, then rerun with --retry to adopt artifacts",
)
else:
state.pop("recovery_required", None)
atomic_json(state_path, state)
emit(
"AGENT_TRACKING",
identity=identity,
stage=stage,
locator=exc.result.get("locator"),
pid=exc.result.get("agent_pid"),
)
return 3
if git(workspace, "rev-parse", "HEAD") != stage_head:
raise CycleError(f"child agent committed unexpectedly: stage={stage}")
refreshed_scope = select_epic(
parse_epics(milestone_path.read_text(encoding="utf-8")), epic.epic_id
)
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),
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():
head = publish(workspace, epic, "user-review")
state.update(status="user-review", event="USER_REVIEW", head=head, next_stage=stage)
atomic_json(state_path, state)
emit("USER_REVIEW", identity=identity, path=str(review_path), head=head)
return 2
if stage == "materialize" and not changed_paths(workspace) and not pairs:
raise CycleError("materialize produced neither direct work nor PLAN pairs")
if stage == "refine":
expected_union = set(state.get("pre_refine_ids", []))
if task_union != expected_union:
raise CycleError(
f"refine changed milestone-task union: before={sorted(expected_union)} after={sorted(task_union)}"
)
if stage == "final-review":
expected_union = set(state.get("pre_refine_ids", []))
if task_union != expected_union:
raise CycleError(
f"final review changed milestone-task union: before={sorted(expected_union)} after={sorted(task_union)}"
)
emit(f"{event_prefix}_FINISHED", identity=identity, plans=len(pairs))
state.pop("current_stage", None)
state.pop("active_result", None)
state.pop("locator", None)
state.pop("recovery_required", None)
if stage == "initial-review":
state["pre_refine_ids"] = sorted(task_union)
checkpoint = publish(workspace, epic, "initial")
state["checkpoint_head"] = checkpoint
emit("INITIAL_CHECKPOINT_PUSHED", identity=identity, head=checkpoint)
elif stage == "final-review":
final_head = publish(workspace, epic, "final")
emit("FINAL_ARTIFACTS_PUSHED", identity=identity, head=final_head)
refreshed = select_epic(
parse_epics(milestone_path.read_text(encoding="utf-8")), epic.epic_id
)
completed_with_plan = sorted(task_union - set(refreshed.incomplete_ids))
if completed_with_plan:
raise CycleError(
"completed Task ids still have active plans: "
+ ",".join(completed_with_plan)
)
remaining_without_plan = sorted(set(refreshed.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)
)
terminal = "EPIC_COMPLETED" if not refreshed.incomplete_ids else "EPIC_WORK_ITEMS_READY"
state.update(status="completed", event=terminal, head=final_head, next_stage=None)
atomic_json(state_path, state)
emit(terminal, identity=identity, head=final_head, plans=len(pairs))
return 0
next_index = STAGES.index(stage) + 1
state["next_stage"] = STAGES[next_index]
state["status"] = "active"
atomic_json(state_path, state)
prior_cycle_status = "active"
raise CycleError("cycle ended without terminal state")
def main(argv: Iterable[str] | None = None) -> int:
args = parser().parse_args(argv)
state_path: Path | None = None
identity = "unknown"
try:
apply_defaults(args)
return cycle(args)
except (CycleError, OSError, ValueError) as exc:
try:
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)
return 2
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,135 @@
from __future__ import annotations
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from types import SimpleNamespace
from unittest import mock
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "run_agent_once.py"
SPEC = importlib.util.spec_from_file_location("run_agent_once", SCRIPT)
assert SPEC and SPEC.loader
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
REPOSITORY = Path(__file__).resolve().parents[5]
def catalog_value(executable: str) -> dict:
target = {
"agent": "runtime-agent",
"model": "runtime-model",
"execution_class": "cloud_model",
"selfcheck_required": False,
"runtime": {
"command": [executable, "{prompt}", "{target_id}"],
"environment": {"RUN_TARGET": "{target_id}"},
},
}
routes = {"worker": {}, "review": {}}
for stage in routes:
for lane in ("local", "cloud"):
for grade in range(1, 11):
routes[stage][f"{lane}-G{grade:02d}"] = {
"candidates": ["primary"]
}
return {"schema_version": "1.0", "targets": {"primary": target}, "routes": routes}
class ExecutionCatalogRunnerTest(unittest.TestCase):
def test_build_command_only_expands_injected_template(self) -> None:
target = SimpleNamespace(
agent="runtime-agent",
model="runtime-model",
catalog_id="target-a",
runtime={"command": ["runner", "--id", "{target_id}", "{prompt}"]},
)
with tempfile.TemporaryDirectory() as raw:
path = Path(raw)
command = MODULE.build_command(
target=target,
prompt="prompt",
workspace=path,
session_id="session-id",
attempt_dir=path,
)
self.assertEqual(command, ["runner", "--id", "target-a", "prompt"])
def test_missing_catalog_is_rejected(self) -> None:
with tempfile.TemporaryDirectory(dir=REPOSITORY) as raw:
workspace = Path(raw) / "workspace"
workspace.mkdir()
subprocess.run(
["git", "init", "-b", "main", str(workspace)],
check=True,
stdout=subprocess.DEVNULL,
)
with mock.patch.dict(os.environ, {}, clear=False):
os.environ.pop(MODULE.CATALOG_ENV, None)
result = MODULE.main(
["--target-id", "primary", "--workspace", str(workspace), "--probe"]
)
self.assertEqual(result, 2)
def test_probe_executes_catalog_target_once_and_records_revision(self) -> None:
with tempfile.TemporaryDirectory(dir=REPOSITORY) as raw:
root = Path(raw)
workspace = root / "workspace"
binary = root / "bin"
workspace.mkdir()
binary.mkdir()
subprocess.run(
["git", "init", "-b", "main", str(workspace)],
check=True,
stdout=subprocess.DEVNULL,
)
fake = binary / "runtime-runner"
fake.write_text(
"#!/bin/sh\nprintf '%s\\n' MILESTONE_AGENT_READY\n",
encoding="utf-8",
)
fake.chmod(0o755)
catalog = root / "catalog.json"
catalog.write_text(json.dumps(catalog_value("runtime-runner")), encoding="utf-8")
result_file = (
workspace
/ ".git"
/ "epic-work-preparation"
/ "probe-cycle"
/ "result.json"
)
with mock.patch.dict(os.environ, {"PATH": f"{binary}:{os.environ['PATH']}"}):
result = MODULE.main(
[
"--execution-catalog",
str(catalog),
"--target-id",
"primary",
"--workspace",
str(workspace),
"--probe",
"--result-file",
str(result_file),
]
)
self.assertEqual(result, 0)
recorded = json.loads(result_file.read_text(encoding="utf-8"))
self.assertEqual(recorded["status"], "succeeded")
self.assertEqual(recorded["target_id"], "primary")
self.assertEqual(recorded["agent"], "runtime-agent")
self.assertEqual(recorded["model"], "runtime-model")
self.assertTrue(recorded["catalog"]["revision"])
locators = list(
(workspace / ".git" / "epic-work-preparation" / "runs").glob(
"*/locator.json"
)
)
self.assertEqual(len(locators), 1)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,342 @@
from __future__ import annotations
import importlib.util
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "run_epic_cycle.py"
SPEC = importlib.util.spec_from_file_location("run_epic_cycle", SCRIPT)
assert SPEC and SPEC.loader
MODULE = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = MODULE
SPEC.loader.exec_module(MODULE)
def command(cwd: Path, *args: str) -> str:
result = subprocess.run(
list(args),
cwd=cwd,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
return result.stdout.strip()
class EpicCycleContractTest(unittest.TestCase):
def test_cycle_requires_runtime_catalog_and_defaults_review_target(self) -> None:
args = MODULE.parser().parse_args(
[
"--workspace", "/workspace",
"--milestone", "milestone.md",
"--epic", "epic",
"--execution-catalog", "/runtime/catalog.json",
"--planner-target", "planner-primary",
]
)
MODULE.apply_defaults(args)
self.assertEqual(args.planner_target, "planner-primary")
self.assertEqual(args.review_target, "planner-primary")
missing = MODULE.parser().parse_args(
["--workspace", "/workspace", "--milestone", "milestone.md", "--epic", "epic"]
)
with self.assertRaises(MODULE.CycleError):
MODULE.apply_defaults(missing)
def test_live_stage_result_requires_tracking_without_relaunch(self) -> None:
with tempfile.TemporaryDirectory() as raw:
root = Path(raw)
workspace = root / "workspace"
state_root = root / "state"
workspace.mkdir()
locator = state_root / "runs" / "locator.json"
locator.parent.mkdir(parents=True)
locator.write_text("{}\n", encoding="utf-8")
result_path = state_root / "attempts" / "materialize.json"
MODULE.atomic_json(
result_path,
{
"workspace": str(workspace),
"label": "sample-epic-materialize",
"status": "tracking",
"agent_pid": os.getpid(),
"agent_process_start_token": MODULE.process_start_token(os.getpid()),
"locator": str(locator),
},
)
with self.assertRaises(MODULE.TrackingRequired):
MODULE.run_agent_stage(
workspace=workspace,
state_root=state_root,
identity="sample-epic",
stage="materialize",
prompt="prompt",
execution_catalog="/runtime/catalog.json",
target_id="planner-primary",
prior_cycle_status="tracking",
retry=False,
)
def test_dead_tracking_result_requires_explicit_artifact_adoption(self) -> None:
with tempfile.TemporaryDirectory() as raw:
root = Path(raw)
workspace = root / "workspace"
state_root = root / "state"
workspace.mkdir()
locator = state_root / "runs" / "locator.json"
locator.parent.mkdir(parents=True)
locator.write_text("{}\n", encoding="utf-8")
result_path = state_root / "attempts" / "materialize.json"
MODULE.atomic_json(
result_path,
{
"workspace": str(workspace),
"label": "sample-epic-materialize",
"status": "tracking",
"agent_pid": 999_999_999,
"agent_process_start_token": "proc:missing",
"locator": str(locator),
},
)
values = {
"workspace": workspace,
"state_root": state_root,
"identity": "sample-epic",
"stage": "materialize",
"prompt": "prompt",
"execution_catalog": "/runtime/catalog.json",
"target_id": "planner-primary",
"prior_cycle_status": "tracking",
}
with self.assertRaises(MODULE.TrackingRecoveryRequired):
MODULE.run_agent_stage(**values, retry=False)
self.assertEqual(
MODULE.run_agent_stage(**values, retry=True),
result_path,
)
def test_parse_and_select_exact_epic(self) -> None:
epics = MODULE.parse_epics(
"""## 기능
### Epic: [first] First Epic
- [x] [done-task] completed
- [ ] [open-task] pending
### Epic: [second] Second Epic
- [ ] [other-task] pending
"""
)
selected = MODULE.select_epic(epics, "first")
self.assertEqual(selected.task_ids, ("done-task", "open-task"))
self.assertEqual(selected.incomplete_ids, ("open-task",))
def test_validate_pair_rejects_task_outside_epic(self) -> None:
with tempfile.TemporaryDirectory() as raw:
workspace = Path(raw)
task = workspace / "agent-task" / "m-sample" / "01_work"
task.mkdir(parents=True)
header = "<!-- task=m-sample/01_work plan=0 tag=TEST milestone-task=outside -->\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.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_injected_target_passes_and_pushes(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-01] Sample\n\n"
"## 상태\n\n[계획]\n\n"
"## 구현 잠금\n\n- 상태: 해제\n- 결정 필요: 없음\n\n"
"## 기능\n\n"
"### Epic: [sample-epic] Sample Epic\n\n"
"- [ ] [large-task] large work\n",
encoding="utf-8",
)
current = workspace / "agent-roadmap" / "current.md"
current.write_text(
"# 현재 로드맵 컨텍스트\n\n"
"## 활성 Milestone\n\n"
"- [계획] Sample\n"
" - 경로: [Sample](phase/phase-one/milestones/sample-milestone.md)\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")
def fake_agent_stage(**values: object) -> None:
if "Materialize this Epic once" not in str(values["prompt"]):
return
task = workspace / "agent-task" / "m-sample-milestone"
task.mkdir(parents=True, exist_ok=True)
header = "<!-- task=m-sample-milestone plan=0 tag=TEST milestone-task=large-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 mock.patch.object(MODULE, "run_agent_stage", side_effect=fake_agent_stage):
result = MODULE.main(
[
"--workspace",
str(workspace),
"--milestone",
str(milestone.relative_to(workspace)),
"--epic",
"sample-epic",
"--execution-catalog",
"/runtime/catalog.json",
"--planner-target",
"planner-primary",
]
)
self.assertEqual(result, 0)
self.assertFalse(command(workspace, "git", "status", "--porcelain=v1"))
self.assertEqual(
command(workspace, "git", "rev-parse", "HEAD"),
command(workspace, "git", "rev-parse", "origin/feature/sample-milestone"),
)
state_path = (
MODULE.git_common_dir(workspace)
/ "epic-work-preparation"
/ "sample-milestone"
/ "sample-epic"
/ "state.json"
)
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",
"--execution-catalog",
"/runtime/catalog.json",
"--planner-target",
"planner-primary",
"--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()
task_root.rmdir()
milestone.write_text(
milestone.read_text(encoding="utf-8").replace(
"- [ ] [large-task] large work",
"- [x] [large-task] large work",
),
encoding="utf-8",
)
command(workspace, "git", "add", "-A", "agent-roadmap", "agent-task")
command(workspace, "git", "commit", "-m", "complete epic work")
command(workspace, "git", "push")
resumed = MODULE.main(
[
"--workspace",
str(workspace),
"--milestone",
str(milestone.relative_to(workspace)),
"--epic",
"sample-epic",
"--execution-catalog",
"/runtime/catalog.json",
"--planner-target",
"planner-primary",
]
)
self.assertEqual(resumed, 0)
self.assertEqual(MODULE.read_state(state_path)["event"], "EPIC_COMPLETED")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,135 @@
---
name: prepare-milestone-workspace
description: 계획 상태의 Milestone을 명시 workspace의 Git Flow feature worktree로 준비하거나, 이미 준비된 현재 feature workspace에서 선택한 한 개·범위·남은 모든 Epic을 검토된 작업으로 변환하고 전체 준비 배리어 뒤 dispatcher를 시작할 때 사용한다. "../sample-feature-worktree 위치에 X 작업 준비해", "현 마일스톤에 두 번째 에픽 작업 시작해", "X 마일스톤에 1,2번째 에픽까지 작업 시작해", "현 마일스톤에 남은 에픽 작업들 시작해" 요청에서 사용한다.
---
# Prepare Milestone Workspace
`<workspace>에 <milestone> 작업 준비해`는 workspace 생성 모드로, `현|<이름> 마일스톤에 <범위> Epic 작업 시작해`는 현재 workspace 실행 모드로 해석한다. 두 모드 모두 정합성 확인부터 선택 Epic 준비와 dispatcher 전환까지 수행한다.
## 목적
계획 가능한 Milestone 하나를 검증된 `feature/<milestone-slug>` workspace로 전환하거나 이미 준비된 동일 branch를 재사용하고, 선택 Epic 전체가 준비된 뒤에만 구현 dispatcher를 시작한다. 의미 정합성은 roadmap 스킬이, branch/worktree/current와 batch lifecycle은 번들 스크립트가 소유한다.
## 입력
- `target-milestone`: 활성 Milestone 이름, id, slug 또는 경로 (필수)
- `workspace`: 생성 모드에서는 feature worktree 절대 경로 또는 develop repository root 기준 상대 경로가 필수다. 현재 workspace 실행 모드에서는 현재 repository root를 사용한다.
- `execution-catalog`: 런타임이 주입한 agent-model 실행 카탈로그 경로. `AGENT_TASK_EXECUTION_CATALOG`로 대신 주입할 수 있다. (필수)
- `planner-target`: 카탈로그에 선언된 Epic materialize/refine 실행 target id. `AGENT_TASK_PLANNER_TARGET`로 대신 주입할 수 있다. (필수)
- `review-target`: 카탈로그에 선언된 review 실행 target id. `AGENT_TASK_REVIEW_TARGET`로 주입하거나 생략하면 `planner-target`과 같다. (선택)
- `target-epics`: `remaining`, `first-incomplete`, 정확한 Epic id/title의 comma list, 또는 문서 순서의 1-based inclusive range `N..M`. 생략하면 `first-incomplete`를 사용한다. (선택)
- `retry`: 기록된 attention/recovery 조건을 사용자가 해소한 뒤 batch를 재개할 때만 사용한다. (선택)
생성 모드의 첫 번째 위치 표현(`<workspace>`)은 workspace로, 두 번째 표현(`<milestone>`)은 대상 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`, `남은 Epic`은 문서 순서의 미완료 Epic 전체를 선택하는 `remaining`으로 변환한다.
## 사전 조건
- 생성 모드는 clean Git Flow develop checkout과 정확히 `[계획]`인 Milestone을 요구한다.
- 현재 workspace 실행 모드는 target slug와 일치하는 `feature/<milestone-slug>` 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`를 확인할 수 있어야 한다.
- 선택 target의 카탈로그 검증과 비대화식 one-shot capability probe가 branch 생성 전에 성공해야 한다.
## 절차
1. **대상과 정합성을 고정한다**
- `agent-roadmap/current.md`를 선택 근거로만 사용하고 target이 명시됐으면 target을 우선한다.
- `sync-milestone-workstate mode=consistency-check`를 먼저 실행한다.
- 기계적으로 고칠 수 있는 drift는 보고된 owner 스킬로 갱신하고 commit/push한 뒤 consistency check를 다시 실행한다.
- 사용자만 결정할 수 있는 drift는 `roadmap-sdd mode=review-ready``agent-roadmap/sdd/<phase-slug>/<milestone-slug>/USER_REVIEW.md`로 남기고 commit/push한 뒤 `USER_REVIEW`로 멈춘다. pre-plan 문제에 `agent-task/**/USER_REVIEW.md`를 만들지 않는다.
2. **실행 모드를 선택한다**
- workspace 위치를 명시한 준비 요청은 생성 모드로 실행한다.
```bash
python3 agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_workspace.py \
--repo "$REPO" \
--milestone "$MILESTONE" \
--workspace "$WORKSPACE" \
--epics "$EPICS" \
--execution-catalog "$EXECUTION_CATALOG" \
--planner-target "$PLANNER_TARGET" \
--review-target "$REVIEW_TARGET"
```
- agent, model, 실행 명령과 provider별 옵션은 스킬이나 스크립트에 고정하지 않고 주입된 카탈로그 target에서 가져온다.
- 스크립트는 develop HEAD와 remote develop의 일치, target probe, branch 충돌, worktree 소유권을 mutation 전에 검사한다.
- branch는 Milestone id가 아니라 파일 basename을 사용한 `feature/<milestone-slug>`다.
- 기존 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" \
--execution-catalog "$EXECUTION_CATALOG" \
--planner-target "$PLANNER_TARGET" \
--review-target "$REVIEW_TARGET"
```
- 현재 workspace가 target feature branch/current와 다르면 다른 worktree를 탐색하거나 branch를 바꾸지 않고 `FAILED`로 멈춘다.
- `remaining`에 미완료 Epic이 없으면 agent/dispatcher를 시작하지 않고 완료 event로 종료한다.
- 두 모드 모두 실행 중 caller LLM이 timer polling, `ps`, state 파일 검사 또는 중복 실행을 하지 않는다.
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이 있으면 공통 `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`: 안전하게 계속할 수 없음
## 실행 결과 검증
- [ ] branch가 `gitflow.prefix.feature + milestone file slug`인가
- [ ] 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와 복구 조건을 보고한다.
## 출력 형식
```text
Milestone workspace preparation
- milestone: <path>
- branch: <feature/milestone-slug>
- workspace: <absolute path>
- 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>
```
## 금지 사항
- `[계획]`이 아니거나 잠긴 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 자동 삭제를 하지 않는다.
- caller LLM이 주기적으로 process나 상태 파일을 모니터링하지 않는다.

View file

@ -0,0 +1,4 @@
interface:
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."

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,704 @@
from __future__ import annotations
import contextlib
import importlib.util
import io
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest import mock
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "prepare_workspace.py"
SPEC = importlib.util.spec_from_file_location("prepare_workspace", SCRIPT)
assert SPEC and SPEC.loader
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
def command(cwd: Path, *args: str) -> str:
result = subprocess.run(
list(args),
cwd=cwd,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
return result.stdout.strip()
class PrepareWorkspaceTest(unittest.TestCase):
def setUp(self) -> None:
self.runtime_environment = mock.patch.dict(
os.environ,
{
"AGENT_TASK_EXECUTION_CATALOG": "/runtime/catalog.json",
"AGENT_TASK_PLANNER_TARGET": "planner-primary",
},
)
self.runtime_environment.start()
def tearDown(self) -> None:
self.runtime_environment.stop()
def test_relative_workspace_is_resolved_from_repository_root(self) -> None:
repo = Path("/tmp/example/sample-repo")
self.assertEqual(
MODULE.resolve_workspace(repo, "../sample-feature-worktree"),
Path("/tmp/example/sample-feature-worktree"),
)
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_remaining_epics_selects_only_incomplete_epics_in_document_order(self) -> None:
epics = MODULE.parse_epics(
"""## 기능
### Epic: [first] First
- [x] [first-task] first
### Epic: [second] Second
- [X] [second-task] second
### Epic: [third] Third
- [ ] [third-task] third
### Epic: [fourth] Fourth
- [ ] [fourth-task] fourth
"""
)
self.assertEqual(
[epic.epic_id for epic in MODULE.select_epics(epics, "remaining")],
["third", "fourth"],
)
self.assertEqual(
MODULE.select_epics(epics[:2], "remaining"),
[],
)
def test_workspace_uses_runtime_injected_catalog_and_targets(self) -> None:
args = MODULE.parser().parse_args(
["--repo", "/repo", "--milestone", "milestone.md", "--workspace", "/workspace"]
)
MODULE.apply_defaults(args)
self.assertEqual(args.execution_catalog, "/runtime/catalog.json")
self.assertEqual(args.planner_target, "planner-primary")
self.assertEqual(args.review_target, "planner-primary")
other = MODULE.parser().parse_args(
[
"--repo",
"/repo",
"--milestone",
"milestone.md",
"--workspace",
"/workspace",
"--planner-target",
"planner-secondary",
"--review-target",
"review-primary",
]
)
MODULE.apply_defaults(other)
self.assertEqual(other.planner_target, "planner-secondary")
self.assertEqual(other.review_target, "review-primary")
def test_agent_probe_bypass_is_test_only(self) -> None:
output = io.StringIO()
with contextlib.redirect_stdout(output), mock.patch.dict(
os.environ, {"AGENT_OPS_TESTING": ""}
):
result = MODULE.main(
[
"--repo",
"/missing-repository",
"--milestone",
"missing.md",
"--workspace",
"/missing-workspace",
"--planner-target",
"planner-primary",
"--skip-agent-probe",
]
)
self.assertEqual(result, 2)
self.assertIn("test-only", output.getvalue())
def test_prepares_git_flow_branch_worktree_and_current(self) -> None:
with tempfile.TemporaryDirectory() as raw:
root = Path(raw)
remote = root / "remote.git"
repo = root / "source"
worktree = root / "feature-worktree"
command(root, "git", "init", "--bare", str(remote))
command(root, "git", "init", "-b", "dev", str(repo))
command(repo, "git", "config", "user.name", "Test Agent")
command(repo, "git", "config", "user.email", "agent@example.test")
command(repo, "git", "config", "gitflow.branch.develop", "dev")
command(repo, "git", "config", "gitflow.prefix.feature", "feature/")
command(repo, "git", "remote", "add", "origin", str(remote))
phase = repo / "agent-roadmap" / "phase" / "phase-one"
milestone = phase / "milestones" / "sample-milestone.md"
milestone.parent.mkdir(parents=True)
(repo / ".gitignore").write_text("agent-roadmap/current.md\n", encoding="utf-8")
(phase / "PHASE.md").write_text(
"# Phase: Sample Phase\n\n## 상태\n\n[계획]\n",
encoding="utf-8",
)
milestone.write_text(
"# Milestone: [sample-01] Sample Milestone\n\n"
"## 상태\n\n[계획]\n\n"
"## 구현 잠금\n\n- 상태: 해제\n- 결정 필요: 없음\n",
encoding="utf-8",
)
command(repo, "git", "add", ".gitignore", "agent-roadmap")
command(repo, "git", "commit", "-m", "init")
command(repo, "git", "push", "-u", "origin", "dev")
output = io.StringIO()
with contextlib.redirect_stdout(output), mock.patch.dict(
os.environ, {"AGENT_OPS_TESTING": "1"}
):
result = MODULE.main(
[
"--repo",
str(repo),
"--milestone",
str(milestone.relative_to(repo)),
"--workspace",
str(worktree),
"--planner-target",
"planner-primary",
"--skip-agent-probe",
]
)
self.assertEqual(result, 0, output.getvalue())
self.assertIn('"event": "WORKSPACE_READY"', output.getvalue())
self.assertEqual(command(worktree, "git", "branch", "--show-current"), "feature/sample-milestone")
self.assertEqual(
command(repo, "git", "rev-parse", "feature/sample-milestone"),
command(repo, "git", "rev-parse", "origin/feature/sample-milestone"),
)
current = (worktree / "agent-roadmap" / "current.md").read_text(encoding="utf-8")
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)
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"],
"execution_catalog": "/runtime/catalog.json",
"planner_target": "planner-primary",
"review_target": "planner-primary",
"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_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)
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

@ -1,18 +1,18 @@
---
name: refine-local-plans
description: 현재 plan들 세분화해, 현재 plan 세분화, 기존 plan 더 나눠, local plan 분리해 같은 요청에서 이미 생성된 미착수 PLAN-local-G??/CODE_REVIEW pair를 최대 3개의 작은 local sibling pair로 나누고 같은 task group의 미착수 index를 의존성 순서로 함께 정렬할 때 사용한다.
name: refine-plans
description: 현재 plan들 세분화해, 현재 plan 세분화, 기존 plan 더 나눠, task 세분화해 같은 요청에서 이미 생성된 미착수 PLAN-*-G??/CODE_REVIEW pair를 lane 구분 없이 최대 3개의 작은 sibling pair로 나누고 같은 task group의 미착수 index를 의존성 순서로 함께 정렬할 때 사용한다.
---
# Refine Local Plans
# Refine Plans
## 목표
이미 생성된 미착수 local pair를 로컬 모델이 수행하기 쉬운 응집된 단위로 이번 실행에서 한 단계만 나눈다. Source/test를 다시 조사하거나 검증을 실행하지 않는다.
이미 생성된 미착수 pair를 실행 주체와 lane에 관계없이 응집된 단위로 이번 실행에서 한 단계만 나눈다. Source/test를 다시 조사하거나 검증을 실행하지 않는다.
## 대상
- 사용자가 지정한 active task group, task path, 또는 `PLAN-local-G??.md`를 사용한다. 대상을 생략하면 미착수 local pair가 있는 task group이 정확히 하나일 때만 진행한다.
- `PLAN-local-G??.md`와 matching `CODE_REVIEW-*-G??.md`가 모두 있고 verdict가 없어야 한다.
- 사용자가 지정한 active task group, task path, 또는 `PLAN-*-G??.md`를 사용한다. 대상을 생략하면 미착수 pair가 있는 task group이 정확히 하나일 때만 진행한다.
- `PLAN-*-G??.md`와 matching `CODE_REVIEW-*-G??.md`가 모두 있고 verdict가 없어야 한다. Build lane은 대상 자격에 사용하지 않는다.
- Review의 구현 완료표·구현 체크리스트가 모두 미체크이고, 구현 소유 기록과 검증 출력이 아직 채워지지 않은 pair만 미착수로 본다.
- `complete.log` 또는 `USER_REVIEW.md`가 있거나 구현이 시작된 pair는 수정하지 않는다.
- 대상 pair, 같은 task group의 미착수 active sibling pair, active directory basename과 archived sibling directory basename만 읽는다. Archive 내부 파일은 읽지 않는다. 새 구현 조사를 위해 source/test/roadmap/archive 본문을 읽지 않는다.
@ -44,15 +44,17 @@ description: 현재 plan들 세분화해, 현재 plan 세분화, 기존 plan 더
- Reindex되는 sibling은 directory basename과 PLAN/review 안의 기존 task path·index·dependency 참조를 새 값으로 바꾼다. 구현 scope, checklist, 검증, routing은 바꾸지 않는다.
4. **현재 PLAN/CODE_REVIEW 형식을 유지한다**
- 각 child는 기존 PLAN을 복제한 뒤 자신의 scope만 남긴다. Header/task, title, background, `구현 체크리스트`, plan item, `수정 파일 요약`, `최종 검증`을 child 경계에 맞게 갱신한다. 기존 PLAN에 `분석 결과`가 있으면 읽은 파일·테스트 공백·심볼 참조·분할 판단·범위 결정 근거도 child 범위로 줄인다.
- `Roadmap Targets`는 전체 결과를 닫는 closure child 하나에만 둔다.
- 각 child는 기존 PLAN을 복제한 뒤 자신의 scope만 남긴다. Header/task, title, background, `구현 체크리스트`, plan item, `수정 파일 요약`, `최종 검증`을 child 경계에 맞게 갱신한다. 기존 PLAN에 `분석 결과`가 있으면 읽은 파일·테스트 공백·심볼 참조·분할 판단·범위 결정 근거도 child 범위로 줄이고 기존 `최종 라우팅`은 제거한다.
- `m-*` parent의 `milestone-task` id 집합은 child들에 보존한다. 각 child는 자신의 범위가 기여하는 parent id의 비어 있지 않은 부분집합을 첫 줄에 기록하고, 여러 child가 같은 id에 기여하면 중복 배치를 허용한다. 모든 child id의 합집합은 parent id 집합과 정확히 같아야 하며 parent 밖 id를 추가하거나 closure child 하나에만 몰아넣지 않는다.
- 각 child PLAN body가 완성되면 parent와 sibling의 이전 lane/G, 점수, route 사유, filename을 입력에서 제거하고 child packet과 기존 PLAN에 이미 있는 사실만 사용해 `finalize-task-routing``evaluation_mode=isolated-reassessment`로 정확히 한 번 실행한다. Routing 때문에 source/test/log를 다시 읽지 않는다.
- `review_rework_count``evidence_integrity_failure`만 route-free 운영 이력으로 전달한다. Child 중 하나라도 `status=routed`가 아니면 원본 pair와 sibling 경로를 바꾸지 않고 중단 사유만 보고한다.
- 각 child의 `최종 라우팅`, build/review lane, G, canonical basename은 finalizer 출력으로 갱신한다. Lane, G, boundary, filename을 수작업으로 만들거나 parent 값을 복사하지 않는다.
- 각 review는 기존 CODE_REVIEW를 복제한 뒤 header/task, 완료표, 구현 checklist, checkpoint, 검증 section을 matching PLAN과 맞춘다. 고정 안내와 review 전용 section의 문구는 유지하되 child task path와 future archive suffix 참조는 갱신한다.
- PLAN과 review는 입력에 이미 있는 section 구조를 유지하며 없는 section을 새로 만들지 않는다. 첫 task header 외의 HTML metadata comment는 출력에서 제거한다.
- PLAN과 review의 첫 줄은 동일한 `task`, `plan`, `tag`를 사용한다. Checklist 문구·순서와 검증 명령을 서로 일치시킨다.
- 기존 build/review lane, G, canonical basename과 `최종 라우팅` 값을 child에 그대로 유지한다. Strict-subset local refinement에서는 `finalize-task-routing`을 다시 실행하지 않는다.
- PLAN과 review의 첫 줄은 동일한 `task`, `plan`, `tag`, `milestone-task`를 사용한다. 비마일스톤 pair에는 `milestone-task`를 추가하지 않는다. Checklist 문구·순서와 검증 명령을 서로 일치시킨다.
5. **최종 pair로 직접 교체한다**
- 모든 child pair, sibling reindex, directory rename map을 먼저 메모리에서 완성하고 최종 경로·archive log 충돌을 확인한다.
- 모든 child pair, finalizer 출력, sibling reindex, directory rename map을 먼저 메모리에서 완성하고 최종 경로·archive log 충돌을 확인한다.
- 원본 active review와 PLAN을 각 파일 basename의 lane/G와 다음 monotonic suffix를 사용해 같은 task directory의 `code_review_*.log`, `plan_*.log`로 archive한다.
- Reindex가 필요한 미착수 directory는 목적지가 비는 순서로 최종 basename으로 이동한다. 임시 directory나 별도 상태 파일이 필요한 충돌이면 분리하지 않는다.
- Indexed target은 첫 child가 최종 basename으로 재사용한다. Task-group root의 single-plan target은 원본 log를 root에 남기고 모든 child directory를 새로 만든다.
@ -68,10 +70,11 @@ description: 현재 plan들 세분화해, 현재 plan 세분화, 기존 plan 더
## 출력
```text
Local plan refinement
Plan refinement
- targets: <원본 PLAN 경로>
- decisions: <유지 | 원본 -> child 목록>
- dependency updates: <변경 내용 또는 없음>
- routing updates: <child = PLAN/CODE_REVIEW lane/G 또는 없음>
- readiness: <pair path = implementation-ready | dependency-waiting>
- document check: passed
```
@ -80,5 +83,5 @@ Local plan refinement
- Source/test 재분석, compile, build, test, lint, formatter, smoke/E2E, live/remote 검증을 실행하지 않는다.
- Package 설치, dependency 다운로드, cache warming을 하지 않는다.
- Child 재귀 분리, cloud pair 분리, 다른 task group 또는 시작·완료 sibling 재인덱싱을 하지 않는다.
- Child 재귀 분리, 다른 task group 또는 시작·완료 sibling 재인덱싱을 하지 않는다.
- 별도 상태 파일, 임시 pair, repository 밖 복구 파일을 만들지 않는다.

View file

@ -1,6 +1,5 @@
---
name: roadmap-sdd
version: 1.0.0
description: 로드맵 Milestone에 녹아 있는 SDD 설계 게이트를 판정, 생성, 갱신, 사용자 리뷰 대기, 잠금 해제, archive 처리할 때 사용한다. 사용자가 SDD, spec gate, 설계 게이트, SDD 필요 여부, SDD 승인 준비, SDD 사용자 리뷰, SDD 잠금 해제, SDD archive를 요청하거나, 큰 Milestone의 구현 잠금이 SDD 필요 상태일 때 사용한다.
---
@ -89,7 +88,7 @@ SDD가 필요한 Milestone은 `구현 잠금`에 아래 필드를 둔다.
- [ ] SDD 잠금이 해제되어 있다
- [ ] SDD 사용자 리뷰가 없거나 승인/해결되었다
- [ ] Acceptance Scenario가 Milestone 기능 Task와 연결되어 있다
- [ ] Evidence Map이 완료 시 `Roadmap Completion`과 최종 검증 evidence로 검증 가능하게 연결되어 있다
- [ ] Evidence Map이 완료 시 `complete.log` 첫 줄의 `milestone-task` id별 집계와 최종 검증 evidence로 검증 가능하게 연결되어 있다
- 결정 필요: 없음
```
@ -164,7 +163,7 @@ SDD 문서는 자체 잠금을 가진다.
5. SDD 상태가 `[승인됨]`이 아니거나 `SDD 잠금``잠금`이면 `blocked`로 보고한다.
6. `USER_REVIEW.md`가 있으면 `blocked`로 보고한다.
7. Acceptance Scenario가 Milestone 기능 Task id와 연결되어 있는지 확인한다.
8. Evidence Map이 완료 시 `Roadmap Completion`과 최종 검증 evidence로 검증될 수 있도록 scenario, task, evidence가 매핑되어 있는지 확인한다.
8. Evidence Map이 완료 시 같은 Milestone task group의 `complete.log``milestone-task` id별로 집계하고 최종 검증 evidence와 대조할 수 있도록 scenario, task, evidence가 매핑되어 있는지 확인한다.
9. 모두 충족하면 `pass`로 보고한다.
### review-ready

View file

@ -10,12 +10,14 @@
- "마일스톤 완료해도 될지 검토", "현 마일스톤 종료 검토", "현재 마일스톤 닫고 다음 마일스톤 지정"처럼 종료 판단, spec sync, roadmap archive, 다음 Milestone 지정을 함께 요구하는 요청은 `complete-milestone`으로 보낸다. 이 흐름 안에서 `update-spec`을 필수 gate로 수행한다. 사용할 테스트 환경 규칙이 있으면 먼저 `update-test mode=resolve-context`의 중립 `Verification Context`를 전달하고, 없거나 불완전하면 complete-milestone의 repository-native fallback을 사용한다.
- 구현 계획 요청에서 선택 Milestone의 구현 잠금이 남아 있으면 `plan`은 구현 계획을 만들지 않고 잠금 차단을 보고한다.
- SDD 생성/갱신/잠금 해제는 `roadmap-sdd` 또는 `update-roadmap` 요청으로 처리한다.
- 런타임이 `origin-task`/`complete-log` 단건 완료 이벤트를 전달한 경우는 `update-roadmap`으로 처리한다.
- 런타임이 새 형식의 `origin-task`/`complete-log` 완료 이벤트를 전달하고 첫 줄에 `milestone-task`가 있으면 `sync-milestone-workstate`로 처리해 같은 Milestone task group의 evidence를 집계한다. first-line metadata가 없고 legacy `Roadmap Completion`만 있는 과거 단건 이벤트는 `update-roadmap` 호환 흐름으로 처리할 수 있다.
- active/archive `complete.log`, 관련 파일, git history를 종합해 Milestone 작업 상태를 복구하거나 확인하는 요청은 `sync-milestone-workstate`로 처리한다.
- 현재 또는 지정 Milestone의 시작 전 정합성 체크, 전체 프로젝트 작업현황과 Milestone의 현재 코드·spec·contract drift 확인, Plan 전 리프레시 필요성 판정은 `sync-milestone-workstate mode=consistency-check`로 처리한다. 이 모드는 read-only이며 기존 완료 evidence용 `check-only`와 구분한다.
- "정합성 체크해", "확인해"는 `consistency-check` 보고에서 멈춘다. "정합성 맞춰줘", "리프레시해", "검사하고 반영해"는 `consistency-check` 결과를 먼저 만든 뒤 미반영 완료 Task는 `sync-milestone-workstate mode=sync`, Milestone/queue/lock은 `update-roadmap`, SDD는 `roadmap-sdd`, living spec은 `update-spec`, contract는 `update-contract`로 넘긴다. 갱신 뒤 `consistency-check`를 다시 실행해 `ready`를 확인한 경우에만 연결된 `plan` 요청을 계속한다.
- plan 요청에 사용할 테스트 환경 규칙이 있으면 `update-test mode=resolve-context`로 read-only `Verification Context`를 만든 뒤 `plan`에 전달한다. 규칙이 없거나 매칭되지 않으면 파일을 생성하지 않고 plan의 repository-native fallback을 사용한다.
- `sync-agent-ui``plan-required`로 라우팅한 작업은 plan pair 생성 뒤 `sync-agent-ui mode=prepare-code-work`로 task/UI 매핑을 기록한다. 일반 code-review PASS와 exact `complete.log` 생성 뒤에는 원래 `task-path``completion-log``sync-agent-ui mode=reconcile-completion`에 전달해 해당 매핑만 정합화한다.
- pending UI task가 WARN/FAIL follow-up plan으로 교체되면 새 pair 생성 뒤 `prepare-code-work`를 다시 실행한다. 매핑 범위가 실제로 달라진 경우에만 검증 후 state helper의 `--replace`를 사용한다.
- pending UI task를 `refine-local-plans`로 분할하거나 sibling reindex해 경로를 바꾼 경우에는 refine 완료 뒤 `sync-agent-ui mode=prepare-code-work`를 호출한다. 기존 경로는 `previous-task-path`, status 대상 전체를 닫는 child 하나는 새 `task-path`로 넘겨 매핑을 rebind하고, scope/evidence가 달라질 때만 검증 후 `--replace`를 사용한다.
- pending UI task를 `refine-plans`로 분할하거나 sibling reindex해 경로를 바꾼 경우에는 refine 완료 뒤 `sync-agent-ui mode=prepare-code-work`를 호출한다. 기존 경로는 `previous-task-path`, status 대상 전체를 닫는 child 하나는 새 `task-path`로 넘겨 매핑을 rebind하고, scope/evidence가 달라질 때만 검증 후 `--replace`를 사용한다.
- `sync-agent-ui``milestone-required`로 라우팅한 작업은 `update-roadmap`이 exact active Milestone을 확정한 뒤 `sync-agent-ui mode=prepare-milestone-work`로 Milestone/UI 매핑을 기록한다. 일반 Milestone 문서에는 agent-ui 전용 완료 필드를 넣지 않는다.
- Milestone 종료 요청에서 exact target이 `.sync-state.json.pending_milestone_work`에 있으면 `complete-milestone mode=check-only`를 먼저 실행한다. 종료 가능 근거를 `sync-agent-ui mode=reconcile-milestone-completion`에 전달해 성공 또는 동일 evidence의 already-reconciled를 확인한 뒤에만 `complete-milestone mode=close`를 실행한다. UI 정합화가 실패하면 close하지 않는다.
@ -37,16 +39,20 @@
| README 작성해줘, README 만들어줘, 프로젝트 설명 문서 만들어줘 | `agent-ops/skills/common/create-readme/SKILL.md` |
| 핸즈오프 남겨, handoff 작성, 인수인계 작성, 다른 세션에서 이어가게 정리, 작업을 이어받도록 기록 | `agent-ops/skills/common/create-handoff/SKILL.md` |
| 로드맵 만들어줘, roadmap 생성, 마일스톤 설계, goal/phase 구조 잡아줘 | `agent-ops/skills/common/create-roadmap/SKILL.md` |
| 현 마일스톤과 작업현황 동기화, 현재 마일스톤 작업현황 동기화, 마일스톤 작업현황 동기화, 마일스톤 완료내역 동기화, agent-task 완료를 마일스톤에 반영, complete.log 후보 스캔, 누락된 Roadmap Completion 복구, 파일/git 기준 작업 상태 확인, 작은 작업 완료 반영, 마일스톤 체크박스 재동기화, 로드맵 작업 완료 상태 동기화 | `agent-ops/skills/common/sync-milestone-workstate/SKILL.md` |
| 현재 마일스톤 정합성 체크해, 특정 마일스톤 정합성 체크해, 마일스톤 시작 전 정합성 확인, plan 전 마일스톤 리프레시, 전체 프로젝트 작업현황과 마일스톤 정합성 확인 | `agent-ops/skills/common/sync-milestone-workstate/SKILL.md` |
| 현 마일스톤과 작업현황 동기화, 현재 마일스톤 작업현황 동기화, 마일스톤 작업현황 동기화, 마일스톤 완료내역 동기화, agent-task 완료를 마일스톤에 반영, complete.log 후보 스캔, milestone-task id별 evidence 집계, 누락된 Roadmap Completion 복구, 파일/git 기준 작업 상태 확인, 작은 작업 완료 반영, 마일스톤 체크박스 재동기화, 로드맵 작업 완료 상태 동기화 | `agent-ops/skills/common/sync-milestone-workstate/SKILL.md` |
| 마일스톤 완료해도 될지 검토해봐, 현 마일스톤 종료 검토, 현재 마일스톤 닫고 다음 마일스톤 지정, 마일스톤 종료해, 마일스톤 완료 검토, 종료 검토 | `agent-ops/skills/common/complete-milestone/SKILL.md` |
| 로드맵 업데이트, roadmap 갱신, 로드맵에 추가, 로드맵 작업 추가, 로드맵 기능 추가, 로드맵 Epic 추가, 로드맵 에픽 추가, 로드맵 Task 추가, 로드맵 태스크 추가, 로드맵 테스크 추가, 로드맵 TODO 추가, 마일스톤에 추가, 마일스톤 추가, 마일스톤 갱신, 마일스톤 아카이브, phase 추가, phase 변경, 페이즈 추가, 페이즈 변경, 현재 마일스톤 변경, 로드맵 한국어 전환, 로드맵 번역 | `agent-ops/skills/common/update-roadmap/SKILL.md` |
| SDD 작성, SDD 생성, SDD 갱신, SDD 필요 여부, SDD gate 확인, SDD 사용자 리뷰, SDD 잠금 해제, SDD 승인 준비, SDD archive, spec gate, 설계 게이트 | `agent-ops/skills/common/roadmap-sdd/SKILL.md` |
| 이 마일스톤은 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 작업 준비해, 현 마일스톤에 N번째 에픽 작업 시작해, 현 마일스톤에 N번째 에픽까지 작업 시작해, Y 마일스톤에 1,2번째 에픽까지 작업 시작해, 현 마일스톤에 남은 에픽 작업들 시작해, Y 마일스톤에 남은 에픽 작업 시작해 | `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 더 나눠, local plan 분리해 | `agent-ops/skills/common/refine-local-plans/SKILL.md` |
| 현재 plan들 세분화해, 현재 plan 세분화, 기존 plan 더 나눠, task 세분화해, plan 분리해 | `agent-ops/skills/common/refine-plans/SKILL.md` |
| 최종 라우팅, task routing, cloud/local 재평가, lane/G 판단, G 등급 재평가, routed filename 결정 | `agent-ops/skills/common/finalize-task-routing/SKILL.md` |
| agent-task 작업 실행, agent-task 작업들 실행해, agent-task 무인 실행, task-group dry-run/live pass, blocked retry | `agent-ops/skills/common/orchestrate-agent-task-loop/SKILL.md` |
| 코드 리뷰해줘, 리뷰 진행해, 리뷰해줘, code review, CODE_REVIEW.md, 리뷰 루프 | `agent-ops/skills/common/code-review/SKILL.md` |
| 커밋해줘, 푸시해줘, commit, push, 반영해줘 | `agent-ops/skills/common/commit-push/SKILL.md` |
| agent-ops 싱크해, agent-ops 동기화해, agentic-framework에 올려줘, agent-ops를 [프로젝트]로 싱크해 | `agent-ops/skills/common/sync-push/SKILL.md` |
@ -55,7 +61,11 @@
라우팅 우선순위:
- 이미 생성된 미착수 local pair의 분할만 요청하면 `refine-local-plans`를 선택한다. 새 plan 작성이나 구현 범위 재분석이 포함되면 `plan`을 선택한다.
- `refine-local-plans` 대상이 아닌 PLAN/CODE_REVIEW 작성 또는 재작성이 요청 범위에 포함되면 `plan`을 선택한다. `plan`이 최종 단계에서 `finalize-task-routing`을 필수 호출한다.
- `X에 Y 작업 준비해`처럼 workspace 위치와 대상 Milestone이 함께 명시되면 `prepare-milestone-workspace` 생성 모드를 선택한다. 상대 workspace는 develop repository root 기준으로 해석한다. 이 형식에서 workspace 위치가 없으면 확인을 요청한다.
- `현 마일스톤에 N번째 에픽 작업 시작해`, `Y 마일스톤에 1,2번째 에픽까지 작업 시작해` 또는 `현|Y 마일스톤에 남은 에픽 작업들 시작해`는 같은 스킬의 현재 workspace 실행 모드를 선택한다. `현 마일스톤`은 current/feature branch의 단일 일치 target, 이름 있는 Milestone은 현재 workspace branch/current와 정확히 일치하는 target만 허용한다. `N번째``N..N`, `N번째까지``1..N`, `1,2번째까지``1..2`, `남은 에픽`은 문서 순서의 미완료 Epic 전체를 뜻하는 `remaining`으로 해석한다. 현재 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`을 필수 호출한다.
- lane/G/canonical filename 판단만 요청되고 plan 문서 작성은 요청되지 않았을 때만 `finalize-task-routing`을 직접 선택한다.
- 코드 리뷰 요청은 `code-review`를 선택한다. WARN/FAIL follow-up은 `code-review -> plan -> finalize-task-routing` 순서를 유지한다.

View file

@ -72,7 +72,7 @@ description: 정합화된 agent-ui 변경분을 코드에 직접 반영하거나
- `agent-ui-docs`는 현재 활성 view/component 문서이고 status가 `계획`인지 확인한다. `가정`, `불명확`, 이미 archive 된 문서는 매핑하지 않는다.
- `frame-docs`는 visual source가 있는 활성 frame-view 문서인지 확인하고 대응 view가 `agent-ui-docs`에 포함됐는지 확인한다. frame-view 자체에는 status를 요구하거나 `구현됨` 전환을 적용하지 않는다.
- `code-paths`는 실제 구현 후보로 좁혀진 경로만 기록한다. 존재하지 않는 새 파일 후보는 경로와 생성 의도가 plan에 명시된 경우에만 허용한다.
- 기존 pending task가 `refine-local-plans`로 분할되거나 sibling reindex로 경로가 바뀌었으면 원래 매핑을 그대로 둘 수 없다. status 대상 전체를 닫는 child를 하나만 고르고 기존 경로를 `previous-task-path`, 그 child 경로를 `task-path`로 전달한다.
- 기존 pending task가 `refine-plans`로 분할되거나 sibling reindex로 경로가 바뀌었으면 원래 매핑을 그대로 둘 수 없다. status 대상 전체를 닫는 child를 하나만 고르고 기존 경로를 `previous-task-path`, 그 child 경로를 `task-path`로 전달한다.
2. **pending entry 기록**
- `agent-ui/.sync-state.json`의 SHA-256을 계산한 뒤 bundled helper의 `prepare` 명령에 `--expected-sha256`으로 넘긴다. helper는 agent-ui 디렉터리 잠금을 잡은 다음 digest를 다시 확인한다.

View file

@ -1,182 +1,251 @@
---
name: sync-milestone-workstate
version: 1.1.0
description: 현 마일스톤과 작업현황 동기화, 현재 마일스톤 작업현황 동기화, 마일스톤 완료내역 동기화, agent-task 완료를 마일스톤에 반영, active/archive complete.log 후보 스캔, 누락된 Roadmap Completion 복구, 작은 작업처럼 agent-task 기록이 없는 완료 내역을 관련 파일과 git 기록까지 종합 확인, 마일스톤 체크박스 재동기화 요청에서 Milestone Task, 상태, Phase/current 라벨을 실제 evidence와 맞추는 절차
description: 현재 또는 지정 Milestone을 시작하기 전 전체 활성 workstate와 대상의 코드·SDD·spec·contract 정합성을 검사해 Plan 준비 상태를 판정하거나, 진행·종료 시 complete.log·파일·git evidence를 Task별로 집계해 상태를 동기화하는 절차. "현재 마일스톤 정합성 체크해", "특정 마일스톤 정합성 체크해", Plan 전 리프레시, 완료내역 반영, 체크박스 재동기화 요청에서 사용한다.
---
# sync-milestone-workstate
## 목적
현재 또는 지정 Milestone의 기능 Task 상태를 실제 작업 evidence와 동기화한다.
새 작업을 배치하거나 구현 계획을 수정하지 않는다.
`complete.log``Roadmap Completion`은 가장 강한 직접 근거로 사용하되, 그 파일이 있더라도 관련 파일과 git history로 최소 sanity pass를 수행한다.
`complete.log`가 없거나 불완전해도 완료가 없다고 단정하지 않는다.
작은 작업이나 수동 수정처럼 `agent-task` 기록이 없을 수 있으므로 Milestone의 관련 경로, 실제 파일 내용, git history, 테스트/검증 흔적을 함께 확인한다.
런타임이 `origin-task``complete-log`를 단건 완료 이벤트로 전달한 일반 반영은 `update-roadmap`을 사용한다.
현재 또는 지정 Milestone의 시작 전 정합성과 진행·종료 workstate를 실제 repository evidence에 맞춘다.
- 시작 전 `consistency-check`는 프로젝트 전체 활성 작업현황을 얕게 확인하고 대상 Milestone의 목표·범위·기능·SDD·spec·contract 가정을 현재 코드와 깊게 대조해 Plan 준비 상태를 판정한다.
- 진행·종료의 `check-only``sync`는 같은 Milestone task group의 완료 evidence를 현재 기능 Task 계약에 집계한다.
`consistency-check``check-only`는 read-only다. 이 스킬은 새 작업을 배치하거나 구현 계획을 만들지 않는다. 시작 전 발견한 미반영 완료는 같은 스킬의 `sync`로 검증·반영하고, 문서 drift는 `update-roadmap`, `roadmap-sdd`, `update-spec`, `update-contract` 책임으로 넘긴다.
새 계약의 `complete.log` 첫 줄 `milestone-task=<id>[,<id>...]`는 해당 완료 작업의 evidence가 어느 Milestone Task에 기여하는지 나타내는 인덱스다. 이 값만으로 Task 완료를 선언하지 않는다. 같은 Milestone task group의 모든 완료 로그를 id별로 모은 뒤 현재 Task 설명, Task 안의 `검증:`, 관련 파일/git evidence, 필요한 SDD Acceptance Scenario와 Evidence Map을 함께 평가해 계약 전체가 충족된 Task만 `[x]`로 바꾼다.
한 plan이 여러 Task id에 기여하거나 여러 plan이 같은 Task id에 기여할 수 있다. 따라서 plan 하나의 PASS와 Milestone Task 하나의 완료를 1:1로 가정하지 않는다.
## 언제 호출할지
- 사용자가 "현 마일스톤과 작업현황 동기화", "현재 마일스톤 작업현황 동기화", "마일스톤 작업현황 동기화"라고 요청할 때
- 사용자가 "마일스톤 완료내역 동기화", "agent-task 완료를 마일스톤에 반영", "complete.log 후보 스캔"이라고 요청할 때
- 사용자가 "누락된 Roadmap Completion 복구", "마일스톤 체크박스 재동기화", "로드맵 작업 완료 상태 동기화"라고 요청할 때
- 사용자가 agent-task에는 없지만 실제 파일/git 기준으로는 완료된 것 같다고 지적할 때
- code-review PASS/archive 이후 runtime completion event 반영이 누락되었는지 확인하고 file-based fallback으로 복구해야 할 때
- 사용자가 "현재 마일스톤 정합성 체크해", "<이름> 마일스톤 정합성 체크해"처럼 현재 또는 지정 Milestone을 시작하기 전 실제 프로젝트 상태와 대조해 달라고 요청할 때
- 사용자가 마일스톤 시작, Plan 전 마일스톤 리프레시, 전체 프로젝트 작업현황과 대상 Milestone 정합성 확인을 요청할 때
- 사용자가 현 마일스톤과 작업현황 동기화, 마일스톤 완료내역 반영, 체크박스 재동기화를 요청할 때
- code-review가 `m-*` PASS completion event와 `complete-log`를 전달했을 때
- `complete.log``milestone-task` id별 evidence를 모아 현재 Task 계약을 평가해야 할 때
- 과거 `Roadmap Completion` 또는 task metadata가 없는 완료 기록을 새 계약과 함께 복구해야 할 때
- agent-task 기록이 없지만 실제 파일/git 기준 완료 가능성을 감사해야 할 때
## 입력
- `target-milestone`: 동기화할 Milestone 이름, slug, 또는 경로. 없으면 `agent-roadmap/current.md`의 활성 Milestone 단일 후보를 사용한다. (선택)
- `complete-log`: 특정 `complete.log` 경로. 지정되면 이 파일을 우선 검증하되, 같은 Milestone slug의 active/archive 후보도 함께 확인한다. (선택)
- `mode`: `sync` 또는 `check-only`. 기본값은 `sync`다. (선택)
- `check-only`: 어떤 파일도 수정하지 않는다. Milestone, Phase, `current.md`, `.agent-roadmap-sync/locks.yaml` 모두 쓰기 금지이며 반영 후보만 보고한다.
- `target-milestone`: 활성 Milestone 이름, slug, 또는 경로. 없으면 `agent-roadmap/current.md`의 단일 활성 Milestone을 사용한다. (선택)
- `complete-log`: 방금 완료된 exact `complete.log` 경로. `sync` 또는 `check-only`에서 이 파일을 우선 검증하되 같은 Milestone task group의 다른 완료 로그도 집계한다. (선택)
- `mode`: `consistency-check`, `sync`, `check-only` 중 하나다. 기본값은 `sync`다. `consistency-check``check-only`에서는 어떤 파일도 수정하지 않는다. (선택)
## 먼저 확인할 것
## 모드 경계
- [ ] `agent-roadmap/current.md`를 읽어 활성 Milestone 후보를 확인한다.
- [ ] 대상 Milestone 문서의 `상태`, `구현 잠금`, `기능`, `완료 리뷰`, `작업 컨텍스트`를 확인한다.
- [ ] 대상 Phase `PHASE.md``Milestone 흐름`에 대상 Milestone 항목이 있는지 확인한다.
- [ ] 대상 Milestone의 `SDD: 필요` 여부와 `SDD 문서`, `USER_REVIEW.md` 존재 여부를 확인한다.
- [ ] 같은 slug의 active task와 archive task `complete.log` 후보를 모두 탐색한다. active만 보고 no-op으로 끝내지 않는다.
- [ ] 관련 파일과 git history를 최소 확인한다. `complete.log`가 없거나 일부 Task만 설명하면 더 깊게 감사한다.
| mode | lifecycle | 책임 | 쓰기 |
|------|-----------|------|------|
| `consistency-check` | 시작 전 | 전역 활성 workstate를 얕게 확인하고 대상 Milestone과 현재 repository의 semantic drift 및 Plan 준비 상태를 판정 | 금지 |
| `check-only` | 진행·종료 | 완료 evidence를 Task별로 집계하고 반영 후보만 판정 | 금지 |
| `sync` | 진행·종료 | 검증된 완료 evidence를 Task 체크와 허용된 Milestone/Phase/current 상태에 반영 | 허용 |
## 실행 절차
`check-only`는 완료 evidence dry-run이고 `consistency-check`는 시작 전 semantic drift 감사다. 두 모드를 같은 의미로 사용하지 않는다.
## consistency-check 실행 절차
1. **대상 Milestone 확정**
- `target-milestone`이 있으면 활성 `agent-roadmap/phase/*/milestones/*.md`에서 정확히 하나를 찾는다.
- `target-milestone`이 없으면 `agent-roadmap/current.md`의 활성 Milestone이 정확히 하나인지 확인한다.
- 대상이 없거나 둘 이상이면 Milestone을 수정하지 않고 target 불명확으로 보고한다.
- 대상 경로가 `agent-roadmap/archive/**`이면 수정하지 않고 archive target 불가로 보고한다.
- 없으면 `agent-roadmap/current.md`의 단일 활성 Milestone을 사용한다.
- 대상이 없거나 둘 이상이거나 archive 경로이면 어떤 파일도 수정하지 않고 `blocked`로 보고한다.
2. **Milestone Task와 evidence scope 읽기**
- Milestone `기능` 섹션의 Task id만 완료 후보로 본다.
- Task id는 `- [ ] [item-id]` 또는 `- [x] [item-id]` 형식에서 추출한다.
- 각 Task의 설명과 `검증:` 문구를 기록한다.
- `구현 잠금`의 상태, `결정 필요`, `SDD: 필요|불필요`, SDD 문서 링크/경로를 확인한다.
- `작업 컨텍스트`의 관련 경로, Milestone 범위, Task 설명의 코드/문서 키워드를 evidence scope로 삼는다.
- 관련 경로가 전혀 없으면 Task 설명에서 검색어를 만들되, 후보가 넓거나 모호하면 Task를 자동 완료하지 않고 scope 불명확으로 보고한다.
2. **프로젝트 활성 workstate snapshot 구성**
- `agent-roadmap/current.md``priority-queue.md`는 존재할 때만 읽고, 없으면 만들지 않은 채 각각 `local current 없음`, `전역 실행 순서 없음`으로 기록한다. 명시 target이 있으면 current 부재만으로 차단하지 않고, queue 부재·파싱 오류는 `refresh-required`로 판정한다.
- `ROADMAP.md`의 Phase 흐름, 모든 active Phase `PHASE.md`의 Milestone 흐름, active Milestone 문서의 H1·상태·구현 잠금 상태와 `.agent-roadmap-sync/locks.yaml` 존재 시 관련 lock을 얕게 읽는다.
- 각 prefix의 active lane head, 대상의 선행 차단과 실제 진행 중인 참조 대상에 대한 동시 차단, Milestone/Phase/current 라벨 불일치와 구현 잠금 상태를 확인한다. prefix 그룹 순서와 일반적인 관련성은 의존성으로 만들지 않는다.
- `current.md`는 활성 후보 창이지 실제 진행 상태가 아니다. 실제 진행 중 여부는 사용자 명시와 active task evidence로 구분한다.
- `agent-task/`의 active task group 이름과 PLAN/CODE_REVIEW/USER_REVIEW 존재 상태를 얕게 확인한다. unrelated task 본문을 전부 읽지 않는다.
- `git status --short`로 checkout의 활성 변경을 확인하되 사용자 변경을 수정하거나 완료 evidence로 간주하지 않는다.
3. **complete.log 후보 수집**
- 대상 Milestone slug를 `<milestone-slug>`로 두고 task group은 `m-<milestone-slug>`로 고정한다.
- active 후보를 찾는다: `agent-task/m-<milestone-slug>/complete.log`, `agent-task/m-<milestone-slug>/**/complete.log`
- archive 후보를 찾는다: `agent-task/archive/*/*/m-<milestone-slug>/complete.log`, `agent-task/archive/*/*/m-<milestone-slug>/**/complete.log`
- `complete-log` 입력이 있으면 그 파일도 후보에 포함하되, `Roadmap Completion`의 Milestone 경로가 대상과 일치해야 직접 반영한다.
- 일반 `agent-task/archive/**` 전체를 훑지 말고 위 패턴에 맞는 같은 milestone task group만 읽는다.
3. **대상 Milestone 계약 읽기**
- `목표`, `상태`, `구현 잠금`, `범위`, `기능`, `범위 제외`, `작업 컨텍스트`와 관련 Phase/queue 설명을 읽는다.
- `SDD: 필요`이면 SDD의 현재 책임 경계, Acceptance Scenario, Evidence Map과 같은 디렉터리의 `USER_REVIEW.md` 존재 여부를 확인한다.
- 대상 task group이 이미 있으면 이 스킬의 완료 로그 수집·Task별 evidence 집계 기준을 재사용해 구현됐지만 Milestone에 반영되지 않은 capability가 있는지 확인한다. 다른 slug의 archive는 읽지 않는다.
4. **Roadmap Completion 직접 근거 검증**
- `complete.log``Roadmap Completion` 섹션이 없으면 직접 Task 체크 근거로 쓰지 않는다. 파일/git evidence를 찾기 위한 힌트로만 사용하고 no-op 사유에 남긴다.
- `Milestone:` 경로가 대상 Milestone 경로와 정확히 일치하지 않으면 해당 파일을 직접 반영하지 않고 mismatch로 보고한다.
- `Completed task ids`의 id가 대상 Milestone의 기존 Task id와 정확히 일치하지 않으면 직접 반영하지 않고 unknown task id로 보고한다.
- 완료 근거는 `PASS` 또는 동등한 완료 판정과 검증 evidence가 있는 항목만 직접 인정한다.
- 같은 Task id에 여러 `complete.log`가 있으면 PASS 근거가 있는 항목을 모으고, 서로 충돌하는 `Not completed task ids`가 있으면 충돌을 보고한다.
4. **현재 repository 기준선 확인**
- 프로젝트와 대상 경로의 domain rule을 먼저 읽는다.
- `agent-spec/`이 있으면 `rules-agent-spec.md`, `agent-spec/index.md`, 매칭되는 현재 spec만 읽는다.
- API, wire, config, schema 또는 컴포넌트 간 계약이 관련되며 `agent-contract/index.md`가 있으면 index와 매칭되는 active contract만 읽는다.
- Milestone `작업 컨텍스트`의 관련 경로에서 `rg --files`, symbol 검색, 현재 코드·config·proto·테스트를 확인한다. 필요할 때만 관련 경로의 `git log`, `git show`, `git diff`를 사용한다.
- 전체 repository를 무차별 감사하지 않고 전역 workstate는 얕게, 대상 Milestone과 직접 의존 기반만 깊게 확인한다.
5. **파일/git evidence 감사**
- 항상 대상 Milestone의 관련 파일과 git history를 최소 확인한다.
- `Roadmap Completion`으로 확인되지 않은 Task가 있거나 사용자가 실제 구현 완료를 지적하면 Task별 상세 감사를 수행한다.
- 관련 파일을 `rg --files <관련 경로>`와 Task 키워드 `rg`로 찾고, 필요한 파일 본문을 읽어 Task 설명과 직접 대응되는 구현/문서/테스트 변경을 확인한다.
- `git log --oneline -- <관련 경로>`와 필요한 경우 `git show --stat --name-only <commit>`로 Milestone 관련 커밋을 확인한다.
- 커밋 메시지만으로 Task를 완료 처리하지 않는다. 커밋이 변경한 파일과 현재 파일 내용이 Task 설명을 충족해야 한다.
- Task 완료 인정 기준:
- 구현/산출물 evidence가 Task 설명과 직접 대응한다.
- Task에 `검증:`이 있으면 해당 검증 명령의 기록, 현재 테스트 실행 결과, 또는 같은 범위를 검증하는 명시 evidence가 있다.
- `validation-tests` 같은 테스트 Task는 테스트 코드와 검증 실행 evidence가 모두 있어야 한다.
- evidence가 의미상 유사하지만 Task id와 연결이 불분명하면 `[x]` 처리하지 않고 `검토 필요`로 보고한다.
5. **semantic drift 분류**
- `already-implemented`: 현재 capability가 구현됐지만 Task/evidence에 반영되지 않았다.
- `stale-assumption`: Milestone 또는 SDD가 현재 존재하지 않는 owner, 경로, 상태 전이, API/wire/config 구조를 전제한다.
- `dependency-drift`: 선행 기반이나 공통 coordinator가 변경돼 계획한 구현·검증·retry/identity 경계를 다시 써야 한다.
- `scope-drift`: 현재 기능 Task가 중복·누락됐거나 현재 책임 경계와 충돌한다.
- `queue-lock-drift`: 실제 의존·배타적 변경 책임과 priority queue blocker 또는 workspace lock이 충돌한다. 경로 중복이나 일반적인 관련성만으로 동시 차단을 만들지 않는다.
- `evidence-gap`: 구현 가능성 판단에 필요한 spec, contract, test 또는 provenance가 부족하다.
- 아직 구현되지 않은 미래 capability 자체는 정상 planned delta다. 현재 구조와 충돌하거나 이미 대체된 경우에만 drift로 분류한다.
6. **SDD Evidence Map 확인**
- `SDD: 필요`인 Milestone은 direct evidence와 file/git evidence 모두 SDD gate를 통과해야 한다.
- SDD `Acceptance Scenarios``Evidence Map`에서 각 완료 후보 Task id와 연결된 scenario가 있는지 확인한다.
- 연결된 scenario의 evidence가 확인한 complete log, 파일 변경, git commit, verification 중 하나로 설명 가능해야 한다.
- SDD 파일이 없거나 `USER_REVIEW.md`가 남아 있거나 Evidence Map 연결이 비어 있으면 Task 체크 또는 `[검토중]` 전환을 하지 않고 차단 사유로 보고한다.
6. **Plan 준비 상태 판정**
- `ready`: 대상이 `[계획]` 또는 `[진행중]`, 구현 잠금과 SDD gate가 해제되고 blocker가 없으며 Plan 입력을 바꿀 semantic drift가 없다.
- `refresh-required`: 대상 identity는 유효하지만 Milestone/SDD/spec/contract/queue를 현재 기준으로 갱신해야 Plan 범위를 확정할 수 있다.
- `blocked`: 대상이 모호하거나 `[스케치]`·`[보류]`, 해당 prefix의 active lane head가 아니거나 구현 잠금·사용자 결정·SDD review·선행/동시/외부 의존이 남아 Plan을 시작할 수 없다.
7. **Milestone 문서 반영**
- `mode=check-only`이면 어떤 파일도 수정하지 않고 반영 후보만 보고한다.
- 검증된 완료 Task id만 `[x]`로 바꾼다. 이미 `[x]`인 항목은 유지한다.
- 일부 Task만 완료되었고 미완료 Task가 남으면 Milestone 상태는 `[진행중]`으로 둔다. 단, 기존 상태가 `[검토중]`, `[보류]`, `[폐기]`이면 자동으로 낮추지 않고 차이만 보고한다.
- 모든 기능 Task가 `[x]`이고 `구현 잠금``해제`, `결정 필요: 없음`, SDD 사용자 리뷰 없음, SDD evidence 충족이면 Milestone 상태를 `[검토중]`으로 바꾼다.
- `[검토중]`으로 바꾸면 `완료 리뷰` 섹션을 만들거나 갱신하고, 사용한 `complete.log`, 파일/git evidence 요약, 완료 Task id, 남은 차단 항목 없음 또는 요약을 1~3줄로 남긴다.
- 모든 Task가 `[x]`여도 구현 잠금이나 SDD gate가 남으면 `[검토중]`으로 바꾸지 않고 `완료 리뷰` 또는 `작업 컨텍스트`에 차단 항목을 남긴다.
- 이 스킬은 Milestone을 `[완료]`로 바꾸거나 archive로 이동하지 않는다.
7. **read-only 보고와 후속 라우팅**
- 어떤 파일도 수정하지 않고 아래 `consistency-check 판정 보고 형식`으로 결과를 남긴다.
- 사용자가 "체크해", "확인해"만 요청했으면 보고 후 멈춘다.
- 사용자가 "정합성 맞춰줘", "리프레시해", "검사하고 반영해"까지 요청했으면 caller/router가 보고 evidence를 유지한다. `already-implemented` Task는 같은 스킬의 `mode=sync`, Milestone/queue/lock은 `update-roadmap`, SDD는 `roadmap-sdd`, living spec은 `update-spec`, contract는 `update-contract`로 넘긴다.
- 후속 갱신 뒤 `consistency-check`를 다시 실행한다. `ready`가 확인되고 별도 Plan 요청 또는 연결된 사용자 요청이 있을 때만 `plan`으로 진행한다.
8. **Phase와 current 라벨 동기화**
- `mode=check-only`이면 이 단계를 쓰기 없이 확인만 한다.
- 대상 Phase `PHASE.md``Milestone 흐름`에서 대상 Milestone 상태 라벨을 Milestone 본문 상태와 맞춘다.
- `agent-roadmap/current.md`에 대상 Milestone이 있으면 상태 라벨을 Milestone 본문 상태와 맞춘다.
- `current.md`에는 `[완료]` 또는 `[폐기]`를 남기지 않는다. 이 스킬은 `[검토중]`까지 유지할 수 있다.
## first-line metadata 계약
`m-*` 완료 로그의 첫 줄은 다음 형식이다.
```markdown
<!-- task=m-<milestone-slug>[/<subtask_dir>] plan=<N> tag=<TAG> milestone-task=<task-id>[,<task-id>...] -->
```
- 주석은 파일 첫 줄에 있어야 한다.
- `task`의 첫 path segment는 집계 대상 `m-<milestone-slug>`와 정확히 같아야 한다.
- `milestone-task`는 비어 있지 않은 쉼표 구분 목록이며 공백과 중복 id를 허용하지 않는다. 각 id는 `rules-roadmap.md`의 item-id 문법과 일치해야 한다.
- 모든 id는 대상 활성 Milestone `기능`에 존재해야 한다.
- PLAN, CODE_REVIEW, `complete.log`는 같은 generation header를 보존한다.
- metadata는 evidence routing 범위다. PASS 또는 id 존재만으로 `[x]` 처리하지 않는다.
## sync/check-only 실행 절차
1. **대상 Milestone 확정**
- `target-milestone`이 있으면 활성 `agent-roadmap/phase/*/milestones/*.md`에서 정확히 하나를 찾는다.
- 없으면 `agent-roadmap/current.md`의 활성 Milestone 단일 후보를 사용한다.
- 대상이 없거나 둘 이상이거나 archive 경로이면 어떤 상태도 수정하지 않고 target 불명확으로 보고한다.
- 대상 Phase `PHASE.md``current.md`의 현재 라벨도 함께 기록한다.
2. **현재 Task 계약 읽기**
- 대상 Milestone `기능``- [ ] [id]``- [x] [id]`만 Task 후보로 추출한다.
- 각 Task 설명, 같은 Task 안의 `검증:`, 관련 Epic 범위, `작업 컨텍스트` 관련 경로를 기록한다.
- `구현 잠금`, `결정 필요`, `SDD: 필요|불필요`, SDD 경로, SDD `USER_REVIEW.md` 존재 여부를 확인한다.
- 동기화 기준은 과거 plan 문구가 아니라 현재 Milestone Task 계약이다. 계약이 변경되어 evidence가 부족해졌으면 자동 완료하지 않는다.
3. **같은 task group의 완료 로그 수집**
- task group을 `m-<milestone-slug>`로 고정한다.
- active 후보: `agent-task/m-<milestone-slug>/complete.log`, `agent-task/m-<milestone-slug>/**/complete.log`
- archive 후보: `agent-task/archive/*/*/m-<milestone-slug>/complete.log`, `agent-task/archive/*/*/m-<milestone-slug>/**/complete.log`
- 전달된 `complete-log`도 포함하되 resolved path가 위 task group과 일치해야 한다.
- 다른 slug의 `agent-task/archive/**`는 탐색하지 않는다.
- 동일 resolved path는 한 번만 센다.
4. **로그 분류와 id별 인덱스 구성**
- canonical 로그는 first-line metadata를 파싱하고 task group, id 문법, 중복, 대상 Milestone의 기존 id 여부를 검증한다.
- 유효한 canonical 로그를 각 `milestone-task` id bucket에 모두 넣는다. 한 로그가 여러 id를 가지면 각 bucket에 기여한다.
- unknown id, 다른 task group, PLAN/review header 불일치, PASS가 아닌 terminal 결과, unresolved Required/Suggested, 상충하는 검증 결과가 있으면 해당 로그를 자동 완료 evidence에서 제외하고 이유를 보고한다.
- 같은 id에 로그가 여러 개면 어느 하나를 대표로 고르지 말고 모두 보존한다.
- first-line metadata가 없고 legacy `Roadmap Completion`이 있는 로그는 명시 완료 주장과 연결 evidence를 호환 근거로 분류한다. 현재 Task 계약과 SDD gate를 다시 평가하며 섹션만 보고 즉시 체크하지 않는다.
- metadata와 `Roadmap Completion`이 모두 없는 legacy 로그는 관련 파일/git 탐색을 위한 힌트로만 사용한다.
5. **Task별 evidence 집계**
- 각 Task id마다 bucket의 모든 `complete.log`에서 `구현/정리 내용`, `최종 검증`, archived plan/review 포인터, final verdict를 모은다.
- 필요한 경우 같은 완료 디렉터리의 exact `plan_*.log``code_review_*.log`만 읽어 metadata 일치와 구체적인 구현·검증 evidence를 확인한다. sibling archive task group 밖으로 확장하지 않는다.
- Milestone의 관련 경로를 `rg --files`와 Task 키워드로 확인하고, 현재 파일 내용이 Task 설명의 각 요구를 실제로 제공하는지 대조한다.
- `git log --oneline -- <관련 경로>`와 필요한 `git show --stat --name-only <commit>`으로 provenance를 보조 확인한다. 커밋 메시지만으로 완료 처리하지 않는다.
- 한 로그가 Task 계약 전체를 충족하면 단독으로 완료 evidence가 될 수 있다. 여러 로그가 각각 세분화된 하위 범위를 맡았다면 합집합이 계약 전체와 검증을 충족할 때 완료 evidence가 된다.
- 로그 수, plan 수, 특정 tag 존재, 파일명 유사성은 완료 기준이 아니다.
6. **검증과 SDD gate 평가**
- Task에 `검증:`이 있으면 집계된 실제 실행 결과, 현재 재실행 결과, 또는 같은 범위를 검증하는 명시 evidence가 있어야 한다.
- 테스트 자체가 산출물인 Task는 테스트 코드와 실행 evidence를 모두 요구한다.
- `SDD: 필요`이면 해당 Task id에 연결된 모든 필요한 Acceptance Scenario와 Evidence Map row를 찾는다. 집계한 로그·파일·검증 evidence가 그 mapping을 충족해야 한다.
- SDD 파일 부재, 미승인/잠금 상태, 남은 SDD `USER_REVIEW.md`, Task mapping 부재는 해당 Task 자동 체크를 차단한다.
- evidence가 의미상 유사하지만 id와의 연결 또는 요구 범위가 불명확하면 `검토 필요`로 남긴다.
7. **완료 판정**
- 다음이 모두 참인 Task만 `[x]` 후보로 판정한다.
- 현재 Task 설명의 capability와 산출물이 모두 확인된다.
- 명시 `검증:`이 충족된다.
- 필요한 SDD mapping과 evidence가 충족된다.
- 집계 evidence 사이에 미완료 선언, 실패, scope 충돌이 없다.
- canonical bucket이 비어 있어도 파일/git 감사로 계약 전체가 명확히 충족되면 완료 후보가 될 수 있으나, 어떤 evidence가 각 요구를 충족했는지 보고한다.
- canonical 로그가 하나 이상 있어도 계약 일부만 충족하면 `[x]` 처리하지 않는다.
8. **Milestone/Phase/current 반영**
- `mode=check-only`이면 후보만 보고하고 파일을 수정하지 않는다.
- 새로 검증된 Task만 `[x]`로 바꾸고 기존 `[x]`는 유지한다. evidence가 상실된 기존 `[x]`는 자동으로 되돌리지 않고 불일치로 보고한다.
- 미완료 Task가 남으면 일반적으로 `[진행중]`을 유지한다. 기존 `[검토중]`, `[보류]`, `[폐기]`는 자동 하향하지 않고 차이를 보고한다.
- 모든 Task가 `[x]`이고 구현 잠금 해제, `결정 필요: 없음`, SDD gate 충족이면 Milestone을 `[검토중]`으로 바꾸고 `완료 리뷰`에 id별 집계 로그와 파일/git evidence를 1~3줄로 요약한다.
- 이 스킬은 `[완료]` 전환이나 archive 이동을 하지 않는다.
- 대상 Phase `PHASE.md``agent-roadmap/current.md`의 대상 라벨을 Milestone 본문과 맞춘다. `current.md`에는 `[완료]` 또는 `[폐기]`를 남기지 않는다.
9. **workspace lock 확인**
- `mode=check-only`이면 관련 lock 여부와 필요한 동기화 후보만 보고하고 `locks.yaml`을 수정하지 않는다.
- `.agent-roadmap-sync/locks.yaml`이 있으면 대상 Milestone identity로 `agent-ops/bin/roadmap-dependency-checker.sh --find-milestone "<project>:<milestone-path>" both "<locks-file>"`를 실행한다.
- 관련 lock이 없으면 결과에 `Workspace 잠금: 관련 lock 없음`을 남긴다.
- 대상 Milestone identity가 어느 entry의 `rely-on.target`과 일치하면 대상 Milestone 상태 기준으로 해당 `rely-on.status`를 동기화한다. `[검토중]` 또는 `[완료]`이면 `enable`, 그 외 상태면 `disable`이다.
- 대상 Milestone identity가 어느 entry의 `locked`와 일치하면 모든 `rely-on.status``enable`인지 결과에 남긴다.
- 이 스킬에서 새 lock을 만들거나 다른 Milestone의 구현 잠금을 직접 해제하지 않는다.
- `.agent-roadmap-sync/locks.yaml`이 있으면 `agent-ops/bin/roadmap-dependency-checker.sh --find-milestone "<project>:<milestone-path>" both "<locks-file>"`를 실행한다.
- 대상이 `rely-on.target`이면 `[검토중]` 또는 `[완료]`에서 `enable`, 그 외에는 `disable`로 동기화한다. `check-only`에서는 쓰지 않는다.
- 새 lock을 만들거나 다른 Milestone 잠금을 직접 해제하지 않는다.
10. **결과 보고**
- 수정 파일과 변경 전/후 상태를 보고한다.
- 읽은 active/archive `complete.log` 후보 수, 확인한 관련 파일/git 범위, 반영한 Task id를 보고한다.
- 반영하지 않은 `complete.log`나 Task가 있으면 이유를 보고한다.
- SDD gate, 완료 리뷰, Workspace lock, 남은 미완료 Task, 검토 필요 Task를 보고한다.
- 대상 Milestone, 수정 파일, `complete.log`, SDD, 사용자 리뷰 같은 문서/산출물 포인터는 raw path만 쓰지 말고 `[표시 제목](상대경로)` Markdown 링크로 보고한다.
10. **검증과 보고**
- `git diff --check`를 실행한다.
- active/archive 후보 수, canonical/legacy/제외 수, Task id별 연결 로그와 판정, 파일/git 범위, SDD gate, 상태 변경, 남은 차단을 보고한다.
- 문서와 산출물 포인터는 Markdown 링크로 쓴다.
## 실행 결과 검증
## 판정 보고 형식
- [ ] 대상 Milestone이 활성 경로에서 정확히 하나로 확정되었는가
- [ ] 같은 `m-<milestone-slug>`의 active/root/nested `complete.log`와 archive/root/nested `complete.log` 후보를 모두 확인했는가
- [ ] archive 확인을 생략하고 active task만 근거로 no-op 처리하지 않았는가
- [ ] `complete.log` 부재만으로 완료 Task 없음이라고 단정하지 않았는가
- [ ] 관련 파일과 git history를 확인했거나, scope 불명확 사유를 보고했는가
- [ ] `Roadmap Completion`의 Milestone 경로와 Task id가 대상 Milestone과 exact match였는가
- [ ] `SDD: 필요`인 경우 SDD 파일, `USER_REVIEW.md` 부재, Acceptance Scenario, Evidence Map 연결을 확인했는가
- [ ] 완료 근거가 있는 Task만 `[x]`로 바꾸었는가
- [ ] 모든 Task 완료와 구현 잠금 해제 조건이 충족된 경우에만 `[검토중]`으로 전환했는가
- [ ] `[검토중]`으로 전환했다면 `완료 리뷰`에 complete.log, 파일/git evidence, 남은 차단 항목이 남았는가
- [ ] Phase `PHASE.md``agent-roadmap/current.md`의 상태 라벨이 Milestone 본문과 일치하는가
- [ ] `[검토중]` 전환만 수행하고 `[완료]` 전환 또는 archive 이동을 하지 않았는가
- [ ] `.agent-roadmap-sync/locks.yaml`이 있으면 관련 lock 여부와 필요한 `rely-on.status` 동기화를 결과에 반영했는가
- [ ] 결과 보고의 문서/산출물 포인터가 raw path만 남지 않고 Markdown 링크로 작성되었는가
- [ ] `git diff --check`를 실행했는가
- 검증 실패 시: 파일을 추가로 추정 수정하지 말고 실패한 항목, 차단 사유, 필요한 evidence 경로를 보고한다.
### consistency-check 판정 보고 형식
## 출력 형식
```markdown
## 마일스톤 시작 정합성 체크
- 대상 Milestone: [<milestone-name>](agent-roadmap/phase/<phase-slug>/milestones/<milestone-slug>.md)
- 모드: consistency-check
- Plan 준비 상태: <ready | refresh-required | blocked>
## 프로젝트 작업현황
- current/Phase/Milestone 상태: <정합 | 불일치 요약>
- queue/lock: <실행 가능 | 차단 요약 | 정리 필요>
- active agent-task: <관련 상태 요약 또는 없음>
- checkout 변경: <관련 변경 요약 또는 없음>
- 깊게 확인한 범위: <문서·코드·config·proto·테스트 링크 또는 없음>
## Drift 판정
- <already-implemented | stale-assumption | dependency-drift | scope-drift | queue-lock-drift | evidence-gap>: <evidence와 영향 또는 없음>
## 후속 라우팅
- 필요한 갱신: <쉼표로 구분한 sync-milestone-workstate mode=sync | update-roadmap | roadmap-sdd | update-spec | update-contract 목록 또는 없음>
- Plan 진행 조건: <충족 | 필요한 선행 조치>
```
### sync/check-only 판정 보고 형식
```markdown
## 동기화 완료
- 대상 Milestone: [<milestone-name>](agent-roadmap/phase/<phase-slug>/milestones/<milestone-slug>.md)
- 모드: <sync | check-only>
- 수정 파일:
- <없음 | [문서명](path)>
- 상태: <변경 없음 | 이전 -> 이후>
- complete.log 후보: active <N>개, archive <N>개, canonical <N>개, legacy <N>개, 제외 <N>
## Task별 집계
- `<task-id>`: <완료 | 미완료 | 검토 필요>
- 연결 로그: <N>
- 충족 evidence: <요약 또는 없음>
- 미충족/충돌: <요약 또는 없음>
- SDD gate: <불필요 | 충족 | 차단>
## 반영 내용
- 상태: <변경 없음 | 이전 -> 이후>
- 완료 Task: <id 목록 또는 없음>
- 미완료 Task: <id 목록 또는 없음>
- 검토 필요 Task: <id 목록 또는 없음>
- complete.log 후보: active <N>개, archive <N>
- 반영한 complete.log:
- <[complete.log](path)>
- 반영 제외 complete.log:
- <[complete.log](path)> - <사유>
- 파일/git evidence:
- <task-id 또는 범위> - <파일/커밋/검증 요약>
- SDD gate: <불필요 | 충족 | 차단: 사유>
- 완료 리뷰: <변경 없음 | 검토중 갱신 | 잠금 차단 기록>
- 새로 완료 처리한 Task: <id 목록 또는 없음>
- 남은 Task: <id 목록 또는 없음>
- 수정 파일: <Markdown 링크 목록 또는 없음>
- Workspace 잠금: <관련 lock 없음 | 상태 요약 | 미확인 사유>
## TODO 항목
- <남은 차단 항목 또는 없음>
- TODO: <남은 차단 항목 또는 없음>
```
## 금지 사항
- active `agent-task/m-<milestone-slug>`만 확인하고 archive `complete.log` 확인 없이 no-op 처리하지 않는다.
- `complete.log` 부재만으로 Task 미완료를 단정하지 않는다.
- 대상 slug와 다른 `agent-task/archive/**` 문서를 일반 탐색하지 않는다.
- 커밋 메시지, 파일명, plan/review log만으로 Task를 `[x]` 처리하지 않는다.
- `complete.log`의 Task id를 의미 유사도, 순서, 파일명으로 보정하지 않는다.
- 파일/git evidence가 있어도 Task 설명과 직접 대응되지 않으면 완료 처리하지 않는다.
- SDD gate가 필요한데 Evidence Map 연결을 확인하지 않고 `[검토중]`으로 전환하지 않는다.
- 구현 잠금이 남아 있거나 `결정 필요`가 있으면 `[검토중]`, `[완료]`, archive를 수행하지 않는다.
- 이 스킬에서 새 Milestone/Epic/Task를 만들거나 기존 id를 바꾸지 않는다.
- 이 스킬에서 Milestone을 `[완료]`로 전환하거나 archive 이동하지 않는다.
- `consistency-check`에서 파일을 수정하거나 Task 체크박스·Milestone 상태·queue·lock을 변경하지 않는다.
- 정상 planned delta를 현재 미구현이라는 이유만으로 drift 또는 차단으로 판정하지 않는다.
- 전역 workstate 확인을 이유로 unrelated 코드, unrelated active task 본문 또는 다른 Milestone의 archive를 무차별 탐색하지 않는다.
- `refresh-required` 또는 `blocked` 상태에서 Plan을 만들거나 구현을 시작하지 않는다.
- `milestone-task` id 존재, plan PASS, 로그 개수만으로 Task를 `[x]` 처리하지 않는다.
- plan 하나와 Task 하나를 1:1로 가정하거나, 같은 id의 여러 로그 중 하나만 임의 선택하지 않는다.
- 새 canonical 로그에 `Roadmap Completion` 작성을 요구하지 않는다.
- legacy `Roadmap Completion`도 현재 Task 계약과 SDD gate 재평가 없이 즉시 반영하지 않는다.
- active task만 보고 archive 후보를 생략하거나 `complete.log` 부재만으로 미완료를 단정하지 않는다.
- 커밋 메시지, 파일명, plan/review log만으로 완료 처리하지 않는다.
- 의미 유사도, 순서, 파일명으로 unknown id를 보정하지 않는다.
- 구현 잠금이나 SDD gate가 남은 상태에서 `[검토중]`, `[완료]`, archive를 수행하지 않는다.
- 새 Milestone/Epic/Task를 만들거나 기존 id를 바꾸지 않는다.

View file

@ -11,7 +11,7 @@ description: 로드맵 업데이트, 로드맵에 추가, 마일스톤 추가·
표준 구조는 `ROADMAP.md -> priority-queue.md -> phase/<phase-slug>/PHASE.md -> phase/<phase-slug>/milestones/<milestone-slug>.md`다.
archive도 같은 Phase scaffold를 유지하며 `archive/phase/<phase-slug>/...` 아래에 둔다.
로드맵 전체를 매 작업마다 읽지 않도록 유지하면서, 브랜치별 로컬 `current.md`의 활성 Phase와 활성 Milestone 창이 실제 작업 후보 목록으로 동작하게 한다.
`priority-queue.md`는 Phase를 가로지르는 실행 순서만 담당하며, Milestone 상세 정보는 복제하지 않는다.
`priority-queue.md`는 Phase를 가로지르는 prefix별 실행 순서, 설명, 최소 차단 예외만 담당하며 Milestone 상세 정보는 복제하지 않는다.
Milestone은 구현 계획이 아니라 방향성, 범위, 위험, 확인 필요 사항을 기록하는 협업 문서로 유지한다.
Epic과 Task는 별도 파일로 분리하지 않고 Milestone 문서의 `기능` 안에서 관리한다. 별도 `완료 기준` 섹션은 만들지 않고, 검증이 필요한 기능에만 같은 Task 안의 `검증:` 문구로 통합한다.
@ -22,7 +22,7 @@ Epic과 Task는 별도 파일로 분리하지 않고 Milestone 문서의 `기능
- Milestone 완료, 보류, 폐기, 신규 추가가 필요할 때
- Phase 완료, 보류, 폐기, 신규 추가가 필요할 때
- 완료 또는 폐기된 Phase/Milestone을 archive로 이동해야 할 때
- 런타임이 `m-<milestone-slug>` task group의 PASS 완료 이벤트를 Milestone에 반영해야 할 때
- 런타임이 first-line `milestone-task`가 없는 legacy `m-<milestone-slug>` PASS 완료 이벤트를 Milestone에 반영해야 할 때. 새 metadata 이벤트는 `sync-milestone-workstate`로 라우팅한다.
- 특정 기능이나 작업을 새 Milestone, 기존 Milestone의 Epic, 기존 Epic의 Task 중 적절한 위치에 추가해야 할 때
- 활성 Phase/Milestone 창에 포함할 목록이 달라졌을 때
- 기존 로드맵을 `phase/<phase-slug>/PHASE.md` scaffold로 마이그레이션하거나 표준화해야 할 때
@ -48,7 +48,7 @@ Epic과 Task는 별도 파일로 분리하지 않고 Milestone 문서의 `기능
- `sdd-path`: SDD 문서 경로. 기본값은 `agent-roadmap/sdd/<phase-slug>/<milestone-slug>/SDD.md` (선택)
- `sdd-review`: SDD 사용자 리뷰 상태. `없음` / `요청됨` / `해결됨` 중 하나 (선택)
- `evidence`: 완료 판단에 사용할 파일, PR, 테스트, 커밋, 사용자 설명 (선택)
- `complete-log`: 런타임 완료 이벤트가 전달한 `complete.log` 경로. `Roadmap Completion` 섹션이 있을 때만 Milestone 기능 Task 체크에 사용한다 (선택)
- `complete-log`: 런타임 완료 이벤트가 전달한 `complete.log` 경로. 첫 줄에 `milestone-task`가 있으면 이 스킬에서 직접 체크하지 않고 `sync-milestone-workstate`로 라우팅한다. metadata가 없는 legacy 로그는 `Roadmap Completion` 호환 검증에만 사용한다 (선택)
- `review-state`: 완료 리뷰 상태. `검토중` / `통과` / `보완 필요` / `보류` / `폐기` 중 하나 (선택)
- `review-comment`: 완료 리뷰에 남길 보완, 보류, 폐기 방향성 또는 근거 메모 (선택)
- `origin-task`: 런타임 완료 이벤트가 전달한 `agent-task/m-<milestone-slug>` 또는 `agent-task/m-<milestone-slug>/<subtask_dir>` 형식의 원래 active task 경로. 이벤트가 최종 archive 경로만 갖고 있으면 런타임이 이 형식으로 정규화해 전달한다 (선택)
@ -86,7 +86,7 @@ agent-roadmap/
```
- `ROADMAP.md`는 전체 목표와 Phase 흐름, `priority-queue.md` 고정 라우팅만 담는다.
- `priority-queue.md`는 Phase를 가로지르는 Milestone 실행 순서만 담는다. 항목은 Milestone 제목 링크와 식별용 한 줄 설명만 둔다.
- `priority-queue.md`는 Phase를 가로지르는 prefix별 Milestone 실행 순서와 차단 예외를 담는다. 항목은 `[prefix-NN]` 제목 링크, 1~2문장 설명, 필요한 exact blocker label만 둔다.
- `PHASE.md`는 해당 Phase의 목표, 상태, Milestone 흐름, Phase 경계를 담는다.
- Milestone 문서는 해당 Phase 하위 `milestones/`에 둔다.
- 완료된 Phase는 `archive/phase/<phase-slug>/PHASE.md`로 이동하고, 하위 Milestone도 같은 archive Phase scaffold 아래에 둔다.
@ -100,7 +100,16 @@ agent-roadmap/
- `current.md`는 git 추적 대상이 아니며, 공유 진행 상태는 `ROADMAP.md`, `PHASE.md`, Milestone 문서, `.agent-roadmap-sync/locks.yaml`에 기록한다.
- `current.md`에는 archive 경로를 넣지 않는다.
- `current.md`에는 `[완료]` 또는 `[폐기]` Phase/Milestone을 남기지 않는다. 완료 후보는 완료 근거와 archive 전환이 정리될 때까지 `[검토중]`으로 둔다.
- `priority-queue.md`에는 archive 경로를 넣지 않는다. 완료 Milestone은 archive 시 제거하고, 폐기 Milestone은 큐에서 제거한다.
- `priority-queue.md`에는 archive 경로를 넣지 않는다. 완료 Milestone은 archive 시 항목과 충족된 `선행 차단` 참조를 제거하고, 폐기 Milestone은 큐에서 제거한다.
## 실행 태그와 차단 예외
- 실행 태그는 `[prefix-NN]` 형식이며 내부 값은 `^[a-z][a-z0-9-]*-[0-9]{2}$`를 따른다. 같은 prefix는 동일 작업 lane, `NN`은 그 lane 안의 선후 순서다.
- 같은 prefix의 항목은 `### <prefix>` 그룹에 모아 index 오름차순으로 둔다. 더 작은 active index가 기본 선행이므로 blocker로 중복하지 않는다.
- 다른 prefix는 기본 병렬이다. 반드시 다른 lane 완료를 기다리는 지점만 exact label `선행 차단:`과 backtick tag 목록으로, 실제 동시 실행 충돌만 낮은 우선순위 항목의 exact label `동시 차단:`과 backtick tag 목록으로 둔다.
- blocker target은 같은 queue의 active tag여야 한다. 프로젝트 간 잠금은 queue tag로 대체하지 않고 `.agent-roadmap-sync/locks.yaml`을 사용한다.
- 실행 태그는 scheduling label이고 Milestone link target/slug는 identity다. 재index해도 파일명을 바꾸지 않으며, H1, 활성 Phase/current 표시, queue 제목과 blocker 참조만 원자적으로 갱신한다.
- Milestone 본문의 선행/후속 설명은 scheduling source가 아니다. 갱신 범위에서는 `작업 컨텍스트``priority-queue.md` 링크와 관련 Milestone 설명만 남긴다.
## 상태와 id
@ -222,10 +231,10 @@ agent-roadmap/
- 런타임 완료 이벤트의 `origin-task`에서 `agent-task/` 다음 첫 path segment가 `m-<milestone-slug>`이면 Milestone 기반 plan/review 완료에서 온 요청으로 본다. `origin-task`는 archive 이동 전 active task 경로 또는 런타임이 그 형태로 정규화한 경로를 사용한다.
- `<milestone-slug>`는 활성 `agent-roadmap/phase/*/milestones/<milestone-slug>.md`에서 정확히 하나만 찾아야 한다. archive Milestone은 target 후보가 아니다.
- target이 없거나 둘 이상이면 Milestone 내용을 추정해 수정하지 말고 target 불명확으로 보고한다.
- target이 확정되어도 `complete-log` 입력이 없거나 해당 파일에 `Roadmap Completion` 섹션이 없으면 Milestone 기능 Task를 체크하지 않고 no-op으로 보고한다. 일반 `m-*` 완료 이벤트만으로 Task를 추정해 체크하지 않는다.
- `Roadmap Completion` 섹션이 있으면 Milestone 경로가 target과 일치하는지, Completed task ids의 각 id가 해당 Milestone의 기존 기능 Task id 하나와 정확히 일치하는지 확인한다. 하나라도 일치하지 않으면 수정하지 말고 target 불일치로 보고한다.
- target Milestone이 `SDD: 필요`이면 런타임 완료 이벤트의 `complete-log`에 있는 `Roadmap Completion`과 최종 검증 evidence가 SDD `Evidence Map`을 충족해야 한다. 사용자가 `update-roadmap` 요청에 별도 evidence를 명시해 수동 반영을 요구한 경우에만 Evidence Map 충족 근거를 보조 근거로 사용할 수 있다. 근거가 없으면 `Roadmap Completion`이 있어도 Task를 체크하지 않고 SDD evidence 부족으로 보고한다.
- 일치하면 PASS evidence, `complete.log`, final archive path, archived plan/review log 경로, code-review 결과 요약을 근거로 `Roadmap Completion`에 적힌 기능 Task만 `[x]`로 갱신한다. target routing 자체는 완료 이벤트의 `m-<milestone-slug>` task group과 `complete.log``Roadmap Completion` 섹션으로 결정한다.
- `complete-log` 첫 줄에 `milestone-task`가 있으면 새 계약 이벤트다. 여기서 단건 PASS를 Task 완료로 해석하거나 체크하지 말고 `sync-milestone-workstate target-milestone=<milestone-slug> complete-log=<path>`로 라우팅한다. 그 스킬이 같은 task group의 모든 완료 로그를 id별로 집계한다.
- first-line metadata가 없는 legacy 로그만 `Roadmap Completion` 호환 흐름을 사용할 수 있다. 섹션이 없으면 no-op이며 일반 `m-*` 완료 이벤트만으로 Task를 추정하지 않는다.
- legacy `Roadmap Completion`이 있으면 Milestone 경로와 Completed task ids를 exact match하고 PASS/검증/SDD Evidence Map을 확인한다. 현재 Task 계약을 충족하지 않거나 충돌 evidence가 있으면 체크하지 않는다.
- 사용자가 이 스킬에 별도 evidence를 명시한 수동 갱신은 일반 완료 리뷰 규칙으로 평가할 수 있지만, `milestone-task` 단건 이벤트를 check-on-pass로 바꾸는 근거로 사용하지 않는다.
- 갱신 후 모든 기능 Task와 Task 안에 명시된 검증이 충족되어도 `구현 잠금`이 해제되어 있지 않으면 `[검토중]` 전환을 하지 않고 잠금 차단으로 보고한다. 기능 Task와 구현 잠금이 모두 충족될 때만 `[검토중]` 전환과 `완료 리뷰` 요청 규칙을 적용한다.
- target Milestone이 `[스케치]`이면 완료 이벤트를 반영하지 말고 상태 불일치로 보고한다. `[스케치]`는 Milestone 기반 `agent-task` 완료 이벤트의 target이 될 수 없다.
@ -304,7 +313,7 @@ target 없는 신규 추가 요청은 append가 아니라 upsert로 처리한다
- 대상 파일을 `agent-roadmap/phase/<phase-slug>/milestones/<milestone-slug>.md`에서 `agent-roadmap/archive/phase/<phase-slug>/milestones/<milestone-slug>.md`로 이동한다.
- 활성 SDD 디렉터리 `agent-roadmap/sdd/<phase-slug>/<milestone-slug>/`가 있으면 `agent-roadmap/archive/sdd/<phase-slug>/<milestone-slug>/`로 이동한다. `USER_REVIEW.md`가 남아 있으면 archive하지 말고 해결 필요로 보고한다.
- 활성 `PHASE.md`의 Milestone 흐름에는 `[완료]` 또는 `[폐기]` 항목을 남기고, 경로는 archive 경로로 바꾼다.
- `priority-queue.md`가 있으면 대상 Milestone의 활성 경로 항목 제거한다. archive 경로로 바꿔 남기지 않는다.
- `priority-queue.md`가 있으면 대상 Milestone의 활성 경로 항목과 해당 실행 태그를 가리키는 충족된 `선행 차단` 참조를 제거한다. `동시 차단` 참조는 target이 더는 active하지 않으므로 함께 제거한다. archive 경로로 바꿔 남기지 않는다.
- 로컬 `current.md`의 활성 Milestone에서는 제거한다.
- `ROADMAP.md`는 Phase 상태나 경로가 바뀌지 않으면 수정하지 않는다.
- 이동한 archive 문서는 스냅샷으로 보존하고 최신 템플릿에 맞춰 재포맷하지 않는다.
@ -325,7 +334,7 @@ target 없는 신규 추가 요청은 append가 아니라 upsert로 처리한다
1. **갱신 범위 결정**
- 요청에서 mode, 대상 Phase/Milestone, placement, placement-unit을 추론한다.
- 런타임 완료 이벤트의 `origin-task` task group이 `m-<milestone-slug>`이면 `target-milestone`을 활성 Milestone 경로 매칭으로 확정한다.
- 런타임 완료 이벤트가 `complete-log`를 전달하면 파일을 읽고 `Roadmap Completion` 섹션 유무와 Completed task ids를 확인한다. 섹션이 없으면 Milestone 기능 Task 체크는 no-op이다. SDD 대상 Milestone이면 SDD `Evidence Map` 충족 여부도 확인한다.
- 런타임 완료 이벤트가 `complete-log`를 전달하면 첫 줄 metadata를 먼저 확인한다. `milestone-task`가 있으면 직접 수정하지 않고 `sync-milestone-workstate`로 라우팅한다. 없는 legacy 로그만 `Roadmap Completion`과 Completed task ids, 필요한 SDD `Evidence Map` 확인한다.
- 구조 전환, 템플릿 보정, current 동기화는 `sync`로 본다.
- `priority-queue.md` 생성, 순서 조정, 깨진 링크 복구, archive/폐기/경로 변경/split/merge 후 큐 정리는 `sync` 또는 `replan`으로 본다.
- 완료/폐기 근거가 충족된 이동은 `archive`로 본다.
@ -363,8 +372,9 @@ target 없는 신규 추가 요청은 append가 아니라 upsert로 처리한다
5. **변경 내용 작성**
- `ROADMAP.md`는 전체 목표, Phase 흐름, 로딩 정책이 바뀔 때만 수정한다.
- `priority-queue.md`가 없고 로드맵 구조를 표준화하거나 생성/동기화하는 요청이면 `roadmap-priority-queue-template.md` 형식으로 만든다.
- `priority-queue.md`는 사용자 순서 조정 요청, archive/폐기 제거, 경로 변경, split/merge, 실행 의미 변경, 깨진 링크 복구 때만 수정한다.
- `priority-queue.md`를 수정할 때는 Milestone 제목 링크와 식별용 한 줄 설명만 남기고, 상태, 목표, 범위, 잠금, 기능, 완료 근거, 의존성 필드를 복제하지 않는다.
- `priority-queue.md`는 사용자 순서 조정 요청, archive/폐기 제거, 경로 변경, split/merge, 실행 의미 변경, 깨진 링크·태그·차단 참조 복구 때만 수정한다.
- `priority-queue.md`를 수정할 때는 prefix 그룹, `[prefix-NN]` Milestone 제목 링크, 1~2문장 설명, 필요한 blocker 줄을 유지하고 상태, 목표, 범위, 잠금, 기능, 완료 근거는 복제하지 않는다.
- 새 Milestone은 관련 lane이 있으면 같은 prefix의 적절한 index를 할당하고, 순차 관계가 없으면 새 prefix를 만든다. 중간 삽입으로 재index가 필요하면 경로는 유지하고 모든 표시 제목과 queue blocker 참조를 같은 변경에서 갱신한다.
- 로컬 `current.md`는 활성 Phase/Milestone 창이 바뀔 때 수정한다.
- `.gitignore`의 Agent-Ops 관리 block에 `agent-roadmap/current.md`가 있는지 확인하고 없으면 추가한다.
- `PHASE.md`는 Phase 목표, 상태, Milestone 흐름, Phase 경계가 바뀔 때 수정한다.
@ -403,6 +413,9 @@ target 없는 신규 추가 요청은 append가 아니라 upsert로 처리한다
- 로컬 `current.md`의 활성 항목이 `[완료]` 또는 `[폐기]` 상태로 남아 있지 않은지 확인한다.
- `agent-roadmap/current.md`가 git 추적 대상으로 남아 있지 않은지 확인한다.
- `agent-roadmap/priority-queue.md`가 있으면 `실행 순서` 섹션이 있고 모든 링크가 실제 활성 Milestone 파일을 가리키는지 확인한다.
- queue 실행 태그가 유일하고 두 자리 index 형식이며 group prefix와 일치하는지, 같은 prefix가 오름차순인지, 각 항목에 1~2문장 설명이 있는지 확인한다.
- `선행 차단`/`동시 차단` 외 blocker label이 없는지, 모든 target이 active tag로 해석되는지, 같은 prefix 기본 순서를 `선행 차단`으로 중복하지 않았는지 확인한다.
- queue tag가 Milestone H1, 활성 `PHASE.md`, 로컬 `current.md`의 표시 제목과 일치하는지 확인한다. tag 불일치는 파일 slug로 추정 보정하지 않고 검증 실패로 보고한다.
- `priority-queue.md`에 archive 경로, `[완료]` archive 대상, `[폐기]` Milestone 항목이 남아 있지 않은지 확인한다.
- `priority-queue.md`가 비어 있는데 활성 실행 후보가 있으면 큐 동기화 필요로 보고한다. 실행 후보가 없으면 빈 큐를 허용한다.
- `ROADMAP.md`의 Phase 경로가 실제 `PHASE.md` 파일을 가리키는지 확인한다.
@ -441,7 +454,7 @@ target 없는 신규 추가 요청은 append가 아니라 upsert로 처리한다
- 로컬 current.md 활성 창 변경 사항
- 완료 리뷰 상태와 남은 차단 항목
- SDD gate 상태와 사용자 리뷰 필요 여부
- 런타임 완료 이벤트의 `origin-task``m-<milestone-slug>`이면 원래 active task 경로와 매칭된 target Milestone, `Roadmap Completion` Task ids 또는 no-op 사유
- 런타임 완료 이벤트의 `origin-task``m-<milestone-slug>`이면 원래 active task 경로와 매칭된 target Milestone, `milestone-task` 집계 라우팅 또는 legacy `Roadmap Completion` Task ids/no-op 사유
- archive 모드이면 이동 경로와 남긴 링크
- 확인 필요로 남긴 항목
@ -468,7 +481,7 @@ target 없는 신규 추가 요청은 append가 아니라 upsert로 처리한다
- SDD gate: <불필요 | 필요-작성 | 필요-잠금 | 필요-사용자 리뷰 | 필요-승인됨 | 변경 없음>
- 승격 조건: <해당 없음 | 추가/수정/미충족 유지/충족 요약>
- 완료 리뷰: <변경 없음 | 검토중 | 통과 | 보완 필요 | 보류 | 폐기>
- runtime m-task 라우팅: <해당 없음 | origin-task -> target Milestone | target 불명확>
- runtime m-task 라우팅: <해당 없음 | milestone-task -> sync-milestone-workstate | legacy Roadmap Completion -> target Milestone | target 불명확>
- Workspace 잠금: <변경 없음 | 관련 lock 없음 | entry 생성/갱신 | rely-on enable | rely-on disable | 미충족 | 런타임 해제 대기>
- 활성 항목: <변경 없음 | Phase/Milestone 추가/제거 요약>
- 아카이브: <변경 없음 | 이동 링크와 남긴 링크>
@ -486,16 +499,16 @@ target 없는 신규 추가 요청은 append가 아니라 upsert로 처리한다
- evidence 없이 Phase, Milestone, Epic, Task를 `[완료]` 또는 `[검토중]`으로 처리하지 않는다.
- 전체 `ROADMAP.md`를 모든 작업의 필수 로딩 파일로 만들지 않는다.
- `ROADMAP.md`에 Milestone 상세 작업 체크리스트를 남기지 않는다.
- `priority-queue.md`에 상태, 목표, 범위, 잠금, 기능, 완료 근거, 의존성 필드를 복제하지 않는다.
- `priority-queue.md`에 상태, 목표, 범위, 잠금, 기능, 완료 근거를 복제하지 않는다. 1~2문장 설명과 최소 blocker 예외는 삭제하지 않는다.
- `priority-queue.md`에 archive 경로를 남기지 않는다.
- 사용자 순서 조정 요청 없이 `priority-queue.md`순서를 에이전트 판단만으로 바꾸지 않는다. 단, archive/폐기 제거, 경로 변경, split/merge, 실행 의미 변경, 깨진 링크 복구는 예외다.
- 사용자 순서 조정 요청 없이 `priority-queue.md`그룹/index/차단 관계를 에이전트 판단만으로 바꾸지 않는다. 단, archive/폐기 제거, 경로 변경, split/merge, 실행 의미 변경, 깨진 링크·태그·차단 참조 복구는 예외다.
- 로컬 `current.md`에 개인별 현재 작업 위치나 완료 상태를 남기지 않는다.
- 로컬 `current.md``agent-roadmap/archive/**` 경로를 남기지 않는다.
- `agent-roadmap/current.md`를 git 추적 대상으로 만들지 않는다.
- archive 문서를 명시 요청 없이 읽거나 최신 템플릿으로 재포맷하지 않는다.
- 완료된 Phase/Milestone 기록을 삭제하지 않는다.
- Epic과 Task를 별도 파일로 분리하지 않는다.
- Phase와 Milestone 이름 또는 파일명에 순번을 강제하지 않는다.
- Phase 이름/파일명이나 Milestone 파일명에 순번을 강제하지 않는다. Milestone 표시 제목의 `[prefix-NN]` 실행 태그는 예외다.
- 에이전트가 확정할 수 없는 결정 항목이 남아 있는데 Milestone의 `구현 잠금``해제`로 바꾸지 않는다.
- `구현 잠금`이 남아 있는 Milestone을 `[검토중]`, `[완료]`, 또는 완료 archive 대상으로 전환하지 않는다. 명시적인 폐기 근거가 있는 `[폐기]` archive는 허용한다.
- 사용자가 지정한 Phase/Milestone/Epic/Task anchor를 무시하지 않는다.