diff --git a/.clinerules b/.clinerules index 55b20d1..0121ef6 100644 --- a/.clinerules +++ b/.clinerules @@ -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-` 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 진행 diff --git a/.cursorrules b/.cursorrules index 55b20d1..0121ef6 100644 --- a/.cursorrules +++ b/.cursorrules @@ -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-` 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 진행 diff --git a/AGENTS.md b/AGENTS.md index 55b20d1..0121ef6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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-` 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 진행 diff --git a/CLAUDE.md b/CLAUDE.md index 55b20d1..0121ef6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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-` 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 진행 diff --git a/GEMINI.md b/GEMINI.md index 55b20d1..0121ef6 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -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-` 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 진행 diff --git a/agent-ops/.version b/agent-ops/.version index fef593f..f6e1a89 100644 --- a/agent-ops/.version +++ b/agent-ops/.version @@ -1 +1 @@ -1.1.178 +1.1.188 diff --git a/agent-ops/bin/init-agent-ops.sh b/agent-ops/bin/init-agent-ops.sh index 64f57f4..7aaeb57 100755 --- a/agent-ops/bin/init-agent-ops.sh +++ b/agent-ops/bin/init-agent-ops.sh @@ -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() { diff --git a/agent-ops/bin/sync.sh b/agent-ops/bin/sync.sh index b0df6f4..752cd59 100755 --- a/agent-ops/bin/sync.sh +++ b/agent-ops/bin/sync.sh @@ -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" } diff --git a/agent-ops/rules/common/philosophy.md b/agent-ops/rules/common/philosophy.md index f7273c5..0266b22 100644 --- a/agent-ops/rules/common/philosophy.md +++ b/agent-ops/rules/common/philosophy.md @@ -21,7 +21,7 @@ - 긴 배경 설명은 README나 별도 참조 문서로 보내고, 실행 문서에는 실행 규칙만 남긴다. - 룰 문서는 협업자가 직접 읽는 계약 문서이므로 한국어 `한다`체로 작성한다. - README, GUIDE, roadmap 문서는 사람이 함께 검토하는 협업 문서이므로 한국어 설명체 또는 존댓말을 사용할 수 있다. -- 스킬 문서는 실행 안정성을 우선한다. 한국어 또는 영어를 사용할 수 있고, 이미 잘 동작하는 절차 계약은 언어 통일만을 위해 수정하지 않는다. +- 스킬 문서의 frontmatter `description`과 Markdown 본문은 영어로 작성한다. 기존 스킬은 언어 통일만을 위해 수정하지 않되, 새로 만들거나 실질적으로 갱신하는 스킬은 영어를 사용한다. 프로젝트가 요구하는 사용자-facing 출력 literal은 해당 언어를 유지할 수 있다. - path, filename, 상태값, id, regex, command, frontmatter key, runtime protocol token은 원문 ASCII 식별자를 유지한다. ## 라우팅 diff --git a/agent-ops/rules/common/rules-roadmap.md b/agent-ops/rules/common/rules-roadmap.md index f2c5dc8..89a642a 100644 --- a/agent-ops/rules/common/rules-roadmap.md +++ b/agent-ops/rules/common/rules-roadmap.md @@ -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과 차단 예외를 한눈에 보는 문서다. +- 각 실행 후보는 `[[-] ]()` 형식의 제목 링크와 1~2문장 설명을 가진다. 실행 태그는 소문자 영문으로 시작하는 ASCII prefix, 하이픈, 두 자리 숫자로 작성한다. 예: `[route-01]`. +- 같은 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은 `## 실행 순서` 아래의 `### ` 그룹, 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-/` 형식을 사용한다. @@ -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-/...`를 `agent-task/m-/...` 형태의 `origin-task`로 정규화해 전달한다. - 런타임 호출에서 매칭되는 활성 Milestone이 없거나 둘 이상이면 추정하지 말고 수동 target 선택이 필요하다고 보고한다. -- `WARN` 또는 `FAIL`은 Milestone 완료 업데이트를 하지 않는다. 일반적으로 같은 `m-` task group에서 후속 계획/리뷰를 이어가지만, code-review의 user-review gate가 트리거되면 연결된 Milestone 잠금 결정을 `USER_REVIEW.md`에 남긴다. +- `WARN` 또는 `FAIL`은 Milestone 완료 업데이트를 하지 않는다. 일반적으로 같은 `m-` 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 선택이나 위치 분석의 후보로 삼지 않는다. - 아카이브 문서는 과거 기록 스냅샷으로 보고, 최신 템플릿이나 스킬 규약에 맞춰 재포맷하지 않는다. diff --git a/agent-ops/rules/common/rules.md b/agent-ops/rules/common/rules.md index 55b20d1..0121ef6 100644 --- a/agent-ops/rules/common/rules.md +++ b/agent-ops/rules/common/rules.md @@ -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-` 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 진행 diff --git a/agent-ops/skills/common/_templates/roadmap-current-template.md b/agent-ops/skills/common/_templates/roadmap-current-template.md index 4ec36ea..adca238 100644 --- a/agent-ops/skills/common/_templates/roadmap-current-template.md +++ b/agent-ops/skills/common/_templates/roadmap-current-template.md @@ -12,9 +12,9 @@ ## 활성 Milestone -- [<스케치 | 계획 | 진행중 | 검토중 | 보류>] +- [<스케치 | 계획 | 진행중 | 검토중 | 보류>] [-] - Phase: [PHASE.md](phase//PHASE.md) - - 경로: [](phase//milestones/.md) + - 경로: [[-] ](phase//milestones/.md) ## 선택 규칙 diff --git a/agent-ops/skills/common/_templates/roadmap-milestone-template.md b/agent-ops/skills/common/_templates/roadmap-milestone-template.md index 42071f6..f70ebf6 100644 --- a/agent-ops/skills/common/_templates/roadmap-milestone-template.md +++ b/agent-ops/skills/common/_templates/roadmap-milestone-template.md @@ -1,4 +1,4 @@ -# Milestone: +# Milestone: [-] ## 위치 @@ -39,7 +39,7 @@ - [ ] - [ ] - [ ] - - [ ] + - [ ] - 결정 필요: <없음 | 아래 목록> - <에이전트가 확정할 수 없는 제품/범위/우선순위/책임 경계 결정 항목> @@ -87,6 +87,6 @@ Task 체크리스트는 Epic 바로 아래의 flat list로 유지하고, 구현 - 관련 경로: `` - 표준선(선택): <기존 구조, 도메인 rule, 플랫폼 관례, 업계 표준으로 진행할 기본 기준> -- 선행 작업: <없음 또는 파일 위치 기준 Markdown 링크(예: [Milestone 이름](../other-milestone.md))/태스크 이름> -- 후속 작업: <없음 또는 파일 위치 기준 Markdown 링크(예: [Milestone 이름](../next-milestone.md))/태스크 이름> +- 실행 순서와 차단 관계: [전역 마일스톤 실행 순서](../../../priority-queue.md) +- 관련 Milestone(선택): <없음 또는 파일 위치 기준 Markdown 링크(예: [Milestone 이름](../other-milestone.md))> - 확인 필요: <없음 | `구현 잠금 > 결정 필요` 또는 [USER_REVIEW.md](../../../sdd///USER_REVIEW.md)로 분리할 항목> diff --git a/agent-ops/skills/common/_templates/roadmap-phase-template.md b/agent-ops/skills/common/_templates/roadmap-phase-template.md index 349fc3d..9faf33c 100644 --- a/agent-ops/skills/common/_templates/roadmap-phase-template.md +++ b/agent-ops/skills/common/_templates/roadmap-phase-template.md @@ -16,8 +16,8 @@ Milestone은 완료, 검토중, 진행중, 계획, 스케치 또는 보류 상 Phase를 가로지르는 실제 다음 작업 선택은 [전역 마일스톤 실행 순서](../../priority-queue.md)를 우선한다. 스케치 Milestone은 아직 구현 가능한 계획이 아니므로 계획 Milestone보다 아래에 둔다. -- [<스케치 | 계획 | 진행중 | 검토중 | 완료 | 보류 | 폐기>] - - 경로: [](milestones/.md) 또는 [archive ](../../archive/phase//milestones/.md) +- [<스케치 | 계획 | 진행중 | 검토중 | 완료 | 보류 | 폐기>] [-] + - 경로: [[-] ](milestones/.md) 또는 [archive ](../../archive/phase//milestones/.md) - 요약: <목표 또는 결과 1문장> ## Phase 경계 diff --git a/agent-ops/skills/common/_templates/roadmap-position-report-template.md b/agent-ops/skills/common/_templates/roadmap-position-report-template.md index 62d0be0..22f27fb 100644 --- a/agent-ops/skills/common/_templates/roadmap-position-report-template.md +++ b/agent-ops/skills/common/_templates/roadmap-position-report-template.md @@ -27,8 +27,10 @@ ## 전역 마일스톤 실행 순서 -- {없음 | [MILESTONE_TITLE](agent-roadmap/phase//milestones/.md): 식별용 한 줄 설명} -- {[MILESTONE_TITLE](agent-roadmap/phase//milestones/.md): 식별용 한 줄 설명 | 큐 정리 필요: 사유} +### {prefix} + +- {없음 | [[prefix-NN] MILESTONE_TITLE](agent-roadmap/phase//milestones/.md): 설명 1~2문장} + - {lane head | 선행 차단: [tag] | 동시 차단: [tag] | 큐 정리 필요: 사유} ## 전체 Phase 흐름 diff --git a/agent-ops/skills/common/_templates/roadmap-priority-queue-template.md b/agent-ops/skills/common/_templates/roadmap-priority-queue-template.md index 9dbaf8e..c8ca7f4 100644 --- a/agent-ops/skills/common/_templates/roadmap-priority-queue-template.md +++ b/agent-ops/skills/common/_templates/roadmap-priority-queue-template.md @@ -1,17 +1,28 @@ # 전역 마일스톤 실행 순서 -이 문서는 Phase를 가로지르는 Milestone 실행 순서만 기록한다. 위에 있는 항목을 먼저 검토한다. +이 문서는 Phase를 가로지르는 Milestone 실행 lane과 차단 예외를 기록한다. 같은 prefix는 순차 실행하고, 다른 prefix는 차단 표기가 없으면 병렬 실행할 수 있다. ## 실행 순서 diff --git a/agent-ops/skills/common/_templates/roadmap-sdd-template.md b/agent-ops/skills/common/_templates/roadmap-sdd-template.md index e811137..7e075e0 100644 --- a/agent-ops/skills/common/_templates/roadmap-sdd-template.md +++ b/agent-ops/skills/common/_templates/roadmap-sdd-template.md @@ -57,7 +57,7 @@ | Scenario | Required Evidence | `agent-task` 연결 | 완료 Evidence 기대 | |----------|-------------------|------------------|---------------------------| -| S01 | | `agent-task/m-/...` | | +| S01 | | `agent-task/m-/...` | | ## Cross-repo Dependencies diff --git a/agent-ops/skills/common/_templates/roadmap-template.md b/agent-ops/skills/common/_templates/roadmap-template.md index 247ad64..a22bfa4 100644 --- a/agent-ops/skills/common/_templates/roadmap-template.md +++ b/agent-ops/skills/common/_templates/roadmap-template.md @@ -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 문서를 기준으로 큐를 재정렬하거나 재생성한다. ## 전체 목표 diff --git a/agent-ops/skills/common/_templates/skill-template.md b/agent-ops/skills/common/_templates/skill-template.md index d3acdc2..b6a4d9b 100644 --- a/agent-ops/skills/common/_templates/skill-template.md +++ b/agent-ops/skills/common/_templates/skill-template.md @@ -1,56 +1,51 @@ --- name: version: 1.0.0 -description: <이 skill이 하는 일을 한 줄로 설명. 트리거 키워드 포함 권장> +description: --- # -## 목적 +## Purpose -<이 skill이 해결하는 문제를 1~2문장으로 설명> + -## 언제 호출할지 +## When to use -- <이 skill을 호출해야 하는 상황 1> -- <이 skill을 호출해야 하는 상황 2> -- <이 skill을 호출해야 하는 상황 3> +- +- -## 입력 + +## Inputs -- ``: <설명> (필수) -- ``: <설명> (선택) +- ``: (required) -## 먼저 확인할 것 + +## Preflight -- [ ] <실행 전 반드시 확인해야 할 조건 1> -- [ ] <실행 전 반드시 확인해야 할 조건 2> +- [ ] -## 실행 절차 +## Procedure -1. **<단계명>** - - <세부 행동> - - <세부 행동> +1. **** + - -2. **<단계명>** - - <세부 행동> +2. **Report the result** + - -3. **결과 보고** - - <출력할 내용> +## Validation -## 실행 결과 검증 +- [ ] +- If validation fails, -- [ ] <실행 후 확인해야 할 성공 조건 1> -- [ ] <실행 후 확인해야 할 성공 조건 2> -- 검증 실패 시: <실패 시 취할 행동 — 롤백, 사용자 알림, 재시도 등> - -## 출력 형식 + +## Output format ``` -<출력 예시> + ``` -## 금지 사항 + +## Prohibitions -- <절대 하면 안 되는 것> -- <절대 하면 안 되는 것> +- diff --git a/agent-ops/skills/common/analyze-roadmap-position/SKILL.md b/agent-ops/skills/common/analyze-roadmap-position/SKILL.md index 9debffd..6e504ef 100644 --- a/agent-ops/skills/common/analyze-roadmap-position/SKILL.md +++ b/agent-ops/skills/common/analyze-roadmap-position/SKILL.md @@ -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)` 링크를 만들지 않는다. diff --git a/agent-ops/skills/common/code-review/SKILL.md b/agent-ops/skills/common/code-review/SKILL.md index 08c7c6d..2399791 100644 --- a/agent-ops/skills/common/code-review/SKILL.md +++ b/agent-ops/skills/common/code-review/SKILL.md @@ -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-`, 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-`) 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-` 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-/` 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 ``. -- 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-` 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-` 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-` 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=` 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_`, 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_`, 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-` 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-`, 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=`, final archive path, `complete.log` path, archived plan/review log paths, and `roadmap-completion=`. -- 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-`, 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=`, final archive path, `complete.log` path, archived plan/review log paths, and `milestone-task=`. +- The runtime consumes that completion event and invokes `sync-milestone-workstate target-milestone= complete-log=`. 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-` 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-` 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. diff --git a/agent-ops/skills/common/code-review/agents/openai.yaml b/agent-ops/skills/common/code-review/agents/openai.yaml index 1ebc3a3..f053bc0 100644 --- a/agent-ops/skills/common/code-review/agents/openai.yaml +++ b/agent-ops/skills/common/code-review/agents/openai.yaml @@ -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." diff --git a/agent-ops/skills/common/code-review/templates/complete-log-template.md b/agent-ops/skills/common/code-review/templates/complete-log-template.md index 0875439..30df42f 100644 --- a/agent-ops/skills/common/code-review/templates/complete-log-template.md +++ b/agent-ops/skills/common/code-review/templates/complete-log-template.md @@ -1,3 +1,5 @@ + + # 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//milestones/.md}` -- Milestone link: [Milestone 문서](agent-roadmap/phase//milestones/.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 - 없음 diff --git a/agent-ops/skills/common/code-review/templates/user-review-template.md b/agent-ops/skills/common/code-review/templates/user-review-template.md index e7a968a..f020490 100644 --- a/agent-ops/skills/common/code-review/templates/user-review-template.md +++ b/agent-ops/skills/common/code-review/templates/user-review-template.md @@ -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//milestones/.md} -- 현재 리뷰 회차: {review-number} -- 최종 판정: {WARN or FAIL} -- 요약: {Milestone 구현 잠금 항목이 실구현을 차단한 이유} +- Type: {milestone-lock | external-execution} +- Target: {agent-roadmap/phase//milestones/.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. diff --git a/agent-ops/skills/common/complete-milestone/SKILL.md b/agent-ops/skills/common/complete-milestone/SKILL.md index cb0cc44..07dd53b 100644 --- a/agent-ops/skills/common/complete-milestone/SKILL.md +++ b/agent-ops/skills/common/complete-milestone/SKILL.md @@ -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`를 실행했는가 diff --git a/agent-ops/skills/common/create-readme/SKILL.md b/agent-ops/skills/common/create-readme/SKILL.md index 074a18b..aa96c46 100644 --- a/agent-ops/skills/common/create-readme/SKILL.md +++ b/agent-ops/skills/common/create-readme/SKILL.md @@ -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`로 가볍게 확인 diff --git a/agent-ops/skills/common/create-roadmap/SKILL.md b/agent-ops/skills/common/create-roadmap/SKILL.md index 621da02..695145a 100644 --- a/agent-ops/skills/common/create-roadmap/SKILL.md +++ b/agent-ops/skills/common/create-roadmap/SKILL.md @@ -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.md` | Phase 목표, 상태, Milestone 흐름, Phase 경계를 담는 문서 | | `agent-roadmap/phase//milestones/.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.md`로 만든다. - Milestone 파일명은 `agent-roadmap/phase//milestones/.md`로 만든다. - ``와 ``는 소문자 영문, 숫자, 하이픈만 사용하고, 공백/언더스코어/순번 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.md)`, Phase 문서에서는 `[Milestone](milestones/.md)`, Milestone 문서에서는 `[ROADMAP.md](../../../ROADMAP.md)`와 `[PHASE.md](../PHASE.md)`를 쓴다. - `경로:`, `Phase:`, `Milestone:`, `SDD 문서:` 같은 필드명을 유지해야 해도 값은 `경로: [PHASE.md](phase//PHASE.md)`처럼 링크로 작성한다. @@ -107,6 +109,7 @@ agent-roadmap/ ## Milestone 작성 규칙 - Milestone 기본 섹션은 `위치`, `목표`, `상태`, `구현 잠금`, `범위`, `기능`, `완료 리뷰`, `범위 제외`, `작업 컨텍스트`다. +- Milestone H1은 `# Milestone: [prefix-NN] ` 형식으로 작성한다. 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별 `### ` 그룹으로 묶고 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 문서에서 `구현 잠금` 섹션을 생략하지 않는다. diff --git a/agent-ops/skills/common/create-skill/SKILL.md b/agent-ops/skills/common/create-skill/SKILL.md index 89ce0c0..a021cf8 100644 --- a/agent-ops/skills/common/create-skill/SKILL.md +++ b/agent-ops/skills/common/create-skill/SKILL.md @@ -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.md` -- `.agent-ops-source` 파일이 **없고** 사용자가 private 또는 operator-local을 명시하면 (타겟 프로젝트): `agent-ops/skills/private//SKILL.md` -- `.agent-ops-source` 파일이 **없고** private 요청이 없으면 (타겟 프로젝트): `agent-ops/skills/project//SKILL.md` +### Select the creation location -## 언제 호출할지 +- If `.agent-ops-source` exists, create `agent-ops/skills/common//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.md`. +- If `.agent-ops-source` does not exist and the user does not request private visibility, create `agent-ops/skills/project//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.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.md - 라우팅 추가: <대상 파일> → <라우팅 축> → ## 주의사항 (해당 시) + - <이 skill이 다루지 않는 범위 또는 주의할 점> ``` -## 실행 결과 검증 +## Prohibitions -- [ ] `agent-ops/skills/{common|project|private}//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. diff --git a/agent-ops/skills/common/create-test/SKILL.md b/agent-ops/skills/common/create-test/SKILL.md index 7563bb5..f9783da 100644 --- a/agent-ops/skills/common/create-test/SKILL.md +++ b/agent-ops/skills/common/create-test/SKILL.md @@ -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`가 있으면 해당 프로젝트 템플릿을 공통 템플릿보다 우선한다. diff --git a/agent-ops/skills/common/finalize-task-routing/SKILL.md b/agent-ops/skills/common/finalize-task-routing/SKILL.md index 1cda11f..b8e8e08 100644 --- a/agent-ops/skills/common/finalize-task-routing/SKILL.md +++ b/agent-ops/skills/common/finalize-task-routing/SKILL.md @@ -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은 이 출력에 포함하지 않는다. ## 완료 확인 diff --git a/agent-ops/skills/common/finalize-task-routing/scripts/finalize-task-policy.sh b/agent-ops/skills/common/finalize-task-routing/scripts/finalize-task-policy.sh index 505dfe1..457165f 100755 --- a/agent-ops/skills/common/finalize-task-routing/scripts/finalize-task-policy.sh +++ b/agent-ops/skills/common/finalize-task-routing/scripts/finalize-task-policy.sh @@ -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:-} diff --git a/agent-ops/skills/common/finalize-task-routing/tests/test_finalize_task_routing.py b/agent-ops/skills/common/finalize-task-routing/tests/test_finalize_task_routing.py index d9a06e1..3be8568 100755 --- a/agent-ops/skills/common/finalize-task-routing/tests/test_finalize_task_routing.py +++ b/agent-ops/skills/common/finalize-task-routing/tests/test_finalize_task_routing.py @@ -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( diff --git a/agent-ops/skills/common/init-agent-ops/SKILL.md b/agent-ops/skills/common/init-agent-ops/SKILL.md index 9070136..a55d167 100644 --- a/agent-ops/skills/common/init-agent-ops/SKILL.md +++ b/agent-ops/skills/common/init-agent-ops/SKILL.md @@ -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 항목을 추가하지 않았는가 - 검증 실패 시: 누락된 파일/항목을 사용자에게 알리고 해당 부분만 보완한다 diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/SKILL.md b/agent-ops/skills/common/orchestrate-agent-task-loop/SKILL.md new file mode 100644 index 0000000..9d3ab55 --- /dev/null +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/SKILL.md @@ -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/` 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 `, `--max-parallel `, 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. diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/agents/openai.yaml b/agent-ops/skills/common/orchestrate-agent-task-loop/agents/openai.yaml new file mode 100644 index 0000000..c53d2ab --- /dev/null +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/agents/openai.yaml @@ -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." diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py b/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py new file mode 100644 index 0000000..1a921fb --- /dev/null +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py @@ -0,0 +1,6273 @@ +#!/usr/bin/env python3 +"""Dispatch every independently-ready agent-task pair until the task group completes.""" + +from __future__ import annotations + +import argparse +import asyncio +import fcntl +import importlib.util +import hashlib +import json +import os +import re +import signal +import shutil +import subprocess +import sys +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +_OBSERVATION_MODULE_NAME = "agent_task_dispatcher_observation" + + +def load_sibling_observation_module(): + loaded = sys.modules.get(_OBSERVATION_MODULE_NAME) + if loaded is not None: + return loaded + spec = importlib.util.spec_from_file_location( + _OBSERVATION_MODULE_NAME, + Path(__file__).with_name("dispatcher_observation.py"), + ) + if spec is None or spec.loader is None: + raise RuntimeError("failed to load dispatcher observation module") + module = importlib.util.module_from_spec(spec) + sys.modules[_OBSERVATION_MODULE_NAME] = module + try: + spec.loader.exec_module(module) + except BaseException: + sys.modules.pop(_OBSERVATION_MODULE_NAME, None) + raise + return module + + +observation = load_sibling_observation_module() +SEP = observation.SEP +banner = observation.banner +attempt_event = observation.attempt_event +validation_claim = observation.validation_claim + +PLAN_RE = re.compile(r"^PLAN-(local|cloud)-G(0[1-9]|10)\.md$") +REVIEW_RE = re.compile(r"^CODE_REVIEW-(local|cloud)-G(0[1-9]|10)\.md$") +PLAN_LOG_RE = re.compile( + r"^plan_(local|cloud)_G(0[1-9]|10)_(0|[1-9][0-9]*)\.log$" +) +REVIEW_LOG_RE = re.compile( + r"^code_review_(local|cloud)_G(0[1-9]|10)_(0|[1-9][0-9]*)\.log$" +) +SUBTASK_RE = re.compile(r"^(?P\d{2})(?:\+(?P\d{2}(?:,\d{2})*))?_[a-z0-9_]+$") +MODIFIED_FILES_HEADINGS = ("Modified Files Summary", "수정 파일 요약") +MODIFIED_FILES_HEADER_CELLS = frozenset({"file", "files", "path", "paths", "파일", "경로"}) +PLACEHOLDER_PATH_RE = re.compile( + r"(?:[<>{}]|\.\.\.|(?:^|[/_.-])(?:tbd|todo|placeholder)(?:$|[/_.-]))", + re.IGNORECASE, +) +IMPLEMENTATION_CHECKLIST_HEADINGS = ("Implementation Checklist", "구현 체크리스트") +# The canonical English and legacy Korean verdict contracts are paired: a +# heading only accepts the verdict label of its own schema. Mixed pairs are not +# a documented schema and must fail closed. +CODE_REVIEW_RESULT_SCHEMAS = ( + ("Code Review Result", "Overall Verdict"), + ("코드리뷰 결과", "종합 판정"), +) +USER_REVIEW_SCHEMAS = ( + { + "status_heading": "Status", + "reason_heading": "Reason", + "type_label": "Type", + "target_label": "Target", + "evidence_heading": "Blocking Evidence", + "evidence_label": "Blocking rationale", + "decision_headings": ("Required User Action",), + "resume_heading": "Resume Condition", + }, + { + "status_heading": "상태", + "reason_heading": "사유", + "type_label": "유형", + "target_label": "연결 대상", + "evidence_heading": "차단 근거", + "evidence_label": "차단 판단 근거", + "decision_headings": ("사용자 조치 또는 결정", "연결 결정 필요"), + "resume_heading": "재개 조건", + }, +) +VERDICT_SCHEMA_MATCHERS = tuple( + ( + re.compile(rf"^##\s*{re.escape(heading)}[ \t]*$", re.MULTILINE), + re.compile( + rf"^(?:-\s*)?(?:\*\*)?{re.escape(label)}(?:\*\*)?\s*:\s*(PASS|WARN|FAIL)[ \t]*$", + re.MULTILINE, + ), + re.compile( + rf"^###\s+{re.escape(label)}[ \t]*$\s*^(?:\*\*)?(PASS|WARN|FAIL)(?:\*\*)?[ \t]*$", + re.MULTILINE, + ), + ) + for heading, label in CODE_REVIEW_RESULT_SCHEMAS +) +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") +PLAN_IDENTITY_RE = re.compile( + r"\A[ \t]*(?:\r?\n|\Z)" +) +MILESTONE_ITEM_RE = re.compile( + rf"^-\s+\[[ xX]\]\s+\[({MILESTONE_TASK_ID_PATTERN})\]", re.MULTILINE +) +MILESTONE_FEATURE_SECTION_RE = re.compile( + r"^##[ \t]+기능[ \t]*\r?\n(?P.*?)(?=^##[ \t]+|\Z)", + re.MULTILINE | re.DOTALL, +) +IMPLEMENTATION_CHECKBOX_RE = re.compile( + r"^-\s+\[([^\]\r\n]*)\]", re.MULTILINE +) +WORK_LOG_NAME = "WORK_LOG.md" +WORK_LOG_ARCHIVE_RE = re.compile(r"^work_log_(\d+)\.log$") +WORK_LOG_HEADER = ( + "| seq | time | event | task | loop | role | attempt | model | result | locator |" +) +WORK_LOG_SEPARATOR = "|---:|---|---|---|---:|---|---:|---|---|---|" +LEGACY_WORK_LOG_HEADER = ( + "| seq | time | event | task | role | attempt | model | result | locator |" +) +LEGACY_WORK_LOG_SEPARATOR = "|---:|---|---|---|---|---:|---|---|---|" +WORK_LOG_EXECUTION_LOOP_RE = re.compile( + r"__p(?P\d+)__(?:worker|selfcheck|review)__a\d+(?=$|[/\\])" +) +AGENT_PROCESS_MARKER_ENV = "AGENT_TASK_EXECUTION_ID" +EXECUTION_CATALOG_PATH: Path | None = None +DISPATCHER_CHILD_BOUNDARY_PROMPT = ( + "You are a child agent already launched by the dispatcher, not the " + "orchestration caller. Execute only the assigned role directly. Do not " + "start, monitor, or wait for orchestration through dispatch.py or " + "orchestrate-agent-task-loop. You may run dispatch.py --validate-plan only " + "when required by plan or code-review finalization because that mode " + "validates one candidate PLAN without starting or monitoring orchestration." +) +REPOSITORY_LANGUAGE_PROMPT = "Follow the repository's language and output rules." +SELF_CHECK_PROMPT_PREFIX = REPOSITORY_LANGUAGE_PROMPT +UTC = timezone.utc +DEFAULT_MAX_PARALLEL = 3 + + +def validated_max_parallel(value: int) -> int: + """Validate and return a non-negative integer for --max-parallel. + + Rejects negative values and non-integer types. Used both for CLI + argument parsing and for programmatic callers that may pass arbitrary + namespaces. + """ + if not isinstance(value, int) or isinstance(value, bool): + raise ValueError( + f"--max-parallel must be an integer >= 0, got {value!r}" + ) + if value < 0: + raise ValueError( + f"--max-parallel must be >= 0, got {value}" + ) + return value + + +STREAM_HEARTBEAT_SECONDS = 30 +MODEL_RESPONSE_STALL_SECONDS = 3 * 60 +RECOVERY_FAILURE_LIMIT = 10 +SELF_CHECK_UNCHECKED_RETRY_LIMIT = 10 +REVIEW_NO_PROGRESS_LIMIT = 10 +PROVIDER_TRANSPORT_FAILURES = frozenset( + {"provider-connection", "provider-stream-disconnect"} +) +FAILURE_EVIDENCE_LIMIT = 2000 +# Used only to reject a stale locator whose dispatcher and agent PIDs are both +# gone. A live process is inspected after silence; it is never killed solely by +# this fallback clock. +RUNTIME_FAILURE_PATTERNS = { + "context-limit": [ + r"context (?:length|window)", r"maximum context", r"prompt is too long", + r"too many tokens", r"token limit", r"exceeded.{0,40}token", + r"output (?:token )?limit", r"maximum output", r"\bmax_tokens\b", + r"response (?:is )?too long", + ], + "provider-quota": [ + r"rate.?limit", r"\bquota\b", r"resource[_ ]?exhausted", r"\b429\b", + r"too many requests", r"usage limit", r"capacity limit", + r"\bsession limit\b", + ], + "model-unavailable": [ + r"model.{0,40}(?:not found|unavailable)", r"overloaded", + r"temporarily unavailable", + ], + "provider-connection": [ + r"\bprovider[_ -]?tunnel[_ -]?error\b", + ( + r"(?:provider|backend|inference (?:server|endpoint))" + r".{0,160}(?:connection refused|dial tcp)" + ), + ], + "provider-stream-disconnect": [ + r"backend connection failed during streaming request", + r"sse stream before done", + r"(?:model|inference) server was unresponsive", + r"backend watchdog", + r"model will be reloaded automatically on retry", + ( + r"(?:provider|backend|sse).{0,160}" + r"curl error: failure when receiving data from the peer" + ), + ], +} +TARGET_FAILOVER_FAILURES = frozenset( + {"context-limit", "provider-quota", "model-unavailable"} +) +RECOVERABLE_RUNTIME_FAILURES = TARGET_FAILOVER_FAILURES | PROVIDER_TRANSPORT_FAILURES +QUALIFIED_FAILOVER_FAILURES = RECOVERABLE_RUNTIME_FAILURES + + +class DispatcherAlreadyRunning(RuntimeError): + """A live dispatcher owns the workspace; this is non-terminal tracking state.""" + + +class DispatcherTerminalStateError(RuntimeError): + """Persistent workspace state prevents safe dispatch before work can start.""" + + +class DispatcherInterruptedWithActiveWork(RuntimeError): + """A control-plane error occurred after one or more agent tasks had started.""" + + +class ExecutionDecisionError(RuntimeError): + """A selector decision is invalid for this task and must fail closed.""" + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def work_log_now_utc() -> str: + return datetime.now(UTC).strftime("%y-%m-%d %H:%M:%SZ") + + +def sha256_file(path: Path | None) -> str: + if path is None or not path.exists(): + return "none" + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +DISPATCHER_SOURCE_PATH = Path(__file__).resolve() +DISPATCHER_SOURCE_SHA256 = sha256_file(DISPATCHER_SOURCE_PATH) +DISPATCHER_PROCESS_STARTED_AT = now_iso() + + +def dispatcher_source_provenance() -> dict[str, Any]: + current_sha256 = sha256_file(DISPATCHER_SOURCE_PATH) + return { + "dispatcher_pid": os.getpid(), + "dispatcher_process_start_token": process_start_token(os.getpid()), + "dispatcher_process_started_at": DISPATCHER_PROCESS_STARTED_AT, + "dispatcher_source_path": str(DISPATCHER_SOURCE_PATH), + "dispatcher_source_sha256": DISPATCHER_SOURCE_SHA256, + "dispatcher_source_current_sha256": current_sha256, + "dispatcher_source_matches_loaded": current_sha256 == DISPATCHER_SOURCE_SHA256, + } + + +def plan_identity(path: Path | None) -> str: + if path is None or not path.exists(): + return "none" + text = path.read_text(encoding="utf-8", errors="replace")[:1024] + match = PLAN_IDENTITY_RE.search(text) + if not match: + return sha256_file(path) + fields = [match.group(name) for name in ("task", "plan", "tag")] + if match.group("milestone_task"): + fields.append(match.group("milestone_task")) + identity = "\0".join(fields) + return "meta:" + hashlib.sha256(identity.encode()).hexdigest() + + +def milestone_task_ids(metadata: re.Match[str]) -> tuple[str, ...]: + value = metadata.group("milestone_task") + return tuple(value.split(",")) if value else () + + +def milestone_feature_task_ids(text: str) -> set[str]: + feature_section = MILESTONE_FEATURE_SECTION_RE.search(text) + if feature_section is None: + return set() + return set(MILESTONE_ITEM_RE.findall(feature_section.group("body"))) + + +def metadata_work_unit_id(metadata: re.Match[str]) -> str: + work_unit_id = ( + f"{metadata.group('task')}::plan-{metadata.group('plan')}::" + f"tag-{metadata.group('tag')}" + ) + if metadata.group("milestone_task"): + work_unit_id += f"::milestone-task-{metadata.group('milestone_task')}" + return work_unit_id + + +def validate_plan_metadata(path: Path, workspace: Path) -> list[str]: + try: + head = path.read_text(encoding="utf-8", errors="replace")[:1024] + except OSError as exc: + return [f"PLAN metadata를 읽을 수 없다: {exc}"] + metadata = PLAN_IDENTITY_RE.search(head) + if metadata is None: + return [ + "첫 줄 generation header를 판별할 수 없다: " + "" + ] + + task_group = metadata.group("task").split("/", 1)[0] + task_ids = milestone_task_ids(metadata) + invalid_ids = [ + task_id + for task_id in task_ids + if MILESTONE_TASK_ID_RE.fullmatch(task_id) is None + ] + if invalid_ids: + return [ + "milestone-task id 문법이 Milestone item-id 계약과 다르다: " + + ", ".join(invalid_ids) + ] + if len(task_ids) != len(set(task_ids)): + return ["milestone-task에 중복 Task id가 있다"] + if not task_group.startswith("m-"): + return ["비마일스톤 task에는 milestone-task를 둘 수 없다"] if task_ids else [] + if not task_ids: + return ["m-* PLAN 첫 줄에는 milestone-task=가 필요하다"] + + slug = task_group[2:] + candidates = sorted( + path + for path in (workspace / "agent-roadmap" / "phase").glob( + f"*/milestones/{slug}.md" + ) + if path.is_file() + ) + if len(candidates) != 1: + return [ + f"milestone-task target은 활성 Milestone과 정확히 하나 매칭되어야 한다: " + f"slug={slug!r}, matches={len(candidates)}" + ] + milestone_text = candidates[0].read_text(encoding="utf-8", errors="replace") + known_ids = milestone_feature_task_ids(milestone_text) + unknown_ids = [task_id for task_id in task_ids if task_id not in known_ids] + if unknown_ids: + return [ + "milestone-task가 활성 Milestone 기능 Task id와 일치하지 않는다: " + + ", ".join(unknown_ids) + ] + return [] + + +def write_json(path: Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + temporary.replace(path) + + +def milestone_work_log_path(task: Task) -> Path: + return ( + task.directory.parent / WORK_LOG_NAME + if "/" in task.name + else task.directory / WORK_LOG_NAME + ) + + +def work_log_task_name(task: Task, role: str) -> str: + """Return the role-specific active artifact shown in the task column.""" + artifact = task.plan if role == "worker" else task.review + if artifact is None: + return task.name + return f"{task.name}/{artifact.name}" + + +def work_log_loop_number(task: Task, execution_id: str) -> int: + """Keep one loop identity even when a reviewer archives the active PLAN.""" + match = WORK_LOG_EXECUTION_LOOP_RE.search(execution_id) + return int(match.group("loop")) if match else plan_number(task) + + +def append_work_log_event( + path: Path, + *, + task_name: str, + loop: int, + event: str, + execution_id: str, + role: str, + attempt: int, + model: str, + result: str, + locator: Path, +) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a+", encoding="utf-8") as stream: + fcntl.flock(stream.fileno(), fcntl.LOCK_EX) + try: + stream.seek(0) + text = stream.read() + if not text: + stream.write( + "# Milestone Work Log\n\n" + "> Dispatcher-owned execution timeline. Workers and reviewers do not edit this file.\n\n" + f"{WORK_LOG_HEADER}\n" + f"{WORK_LOG_SEPARATOR}\n" + ) + sequence = 1 + else: + stream.seek(0, os.SEEK_END) + if WORK_LOG_HEADER not in text: + if not text.endswith("\n"): + stream.write("\n") + stream.write( + "\n## Dispatcher Timeline\n\n" + "> Dispatcher-owned. Workers and reviewers do not edit this section.\n\n" + f"{WORK_LOG_HEADER}\n" + f"{WORK_LOG_SEPARATOR}\n" + ) + sequence = 1 + max( + ( + int(match.group(1)) + for match in re.finditer(r"^\|\s*(\d+)\s*\|", text, re.MULTILINE) + ), + default=0, + ) + if not text.endswith("\n"): + stream.write("\n") + + def cell(value: Any) -> str: + return str(value).replace("|", r"\|").replace("\n", " ") + + stream.write( + f"| {sequence} | {work_log_now_utc()} | {cell(event)} | " + f"{cell(task_name)} | " + f"{loop} | {cell(role)} | {attempt} | {cell(model)} | {cell(result)} | " + f"{cell(locator.resolve())} |\n" + ) + stream.flush() + finally: + fcntl.flock(stream.fileno(), fcntl.LOCK_UN) + return path + + +def append_milestone_event( + task: Task, + *, + event: str, + execution_id: str, + role: str, + attempt: int, + model: str, + result: str, + locator: Path, +) -> Path: + return append_work_log_event( + milestone_work_log_path(task), + task_name=work_log_task_name(task, role), + loop=work_log_loop_number(task, execution_id), + event=event, + execution_id=execution_id, + role=role, + attempt=attempt, + model=model, + result=result, + locator=locator, + ) + + +def safe_name(value: str) -> str: + return re.sub(r"[^A-Za-z0-9_.-]+", "__", value).strip("_") or "task" + + +@dataclass(frozen=True) +class AgentSpec: + cli: str + model: str + display: str + native_resume: bool = False + target_id: str | None = None + execution_class: str = "cloud_model" + selfcheck_required: bool = False + runtime: dict[str, Any] = field(default_factory=dict) + +def agent_spec_from_record(record: dict[str, Any]) -> AgentSpec | None: + cli = str(record.get("cli") or "") + model = str(record.get("model") or "") + if not cli or not model: + return None + runtime = record.get("runtime") + if not isinstance(runtime, dict): + runtime = {} + target_id = record.get("target_id") + if target_id is not None and (not isinstance(target_id, str) or not target_id): + return None + execution_class = record.get("execution_class", "cloud_model") + if execution_class not in {"local_model", "cloud_model"}: + return None + selfcheck_required = record.get("selfcheck_required", False) + if not isinstance(selfcheck_required, bool): + return None + native_resume = bool(runtime.get("native_session_monitor")) + display = f"{cli}/{model}" + return AgentSpec( + cli, + model, + display, + native_resume=native_resume, + target_id=target_id, + execution_class=execution_class, + selfcheck_required=selfcheck_required, + runtime=dict(runtime), + ) + + +def agent_spec_from_locator(locator: Path | None) -> AgentSpec | None: + if locator is None: + return None + try: + record = json.loads(locator.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(record, dict): + return None + return agent_spec_from_record(record) + + +@dataclass(frozen=True) +class NativeSessionState: + phase: str + expected_tool_call_ids: tuple[str, ...] = () + completed_tool_call_ids: tuple[str, ...] = () + pending_tool_call_ids: tuple[str, ...] = () + reason: str = "" + + +@dataclass +class Task: + name: str + directory: Path + plan: Path | None + review: Path | None + user_review: Path | None + recovery: bool + errors: list[str] = field(default_factory=list) + index: int = 0 + deps: tuple[str, ...] = () + write_set: set[str] = field(default_factory=set) + write_set_known: bool = False + plan_hash: str = "none" + lane: str | None = None + grade: int | None = None + + +def task_target_files(task: Task) -> list[str]: + """Return the canonical plan-declared file targets for dispatcher output.""" + return sorted(str(Path(path).resolve()) for path in task.write_set) + + +def task_observation_lines(task: Task) -> list[str]: + """Render the task directory and declared file targets for operator logs.""" + lines = [f"task_dir={task.directory.resolve()}"] + targets = task_target_files(task) + if targets: + lines.extend(f"target_file={path}" for path in targets) + else: + lines.append( + "target_file=unavailable (Modified Files Summary has no valid file claim)" + ) + return lines + + +def next_execution_identity( + store: StateStore, + task: Task, + role: str, +) -> tuple[int, str]: + attempt = store.next_attempt(task, role) + identity = ( + f"{safe_name(task.name)}__p{plan_number(task)}__{role}__a{attempt:02d}" + ) + return attempt, identity + + +class StateStore: + def __init__(self, workspace: Path): + self.workspace = workspace.resolve() + self.workspace_id = hashlib.sha256( + str(self.workspace).encode() + ).hexdigest()[:16] + git_marker = self.workspace / ".git" + git_directory: Path | None = None + if git_marker.is_dir(): + git_directory = git_marker + elif git_marker.is_file(): + marker = git_marker.read_text(encoding="utf-8", errors="replace").strip() + if marker.startswith("gitdir:"): + candidate = Path(marker.split(":", 1)[1].strip()) + git_directory = ( + candidate + if candidate.is_absolute() + else (self.workspace / candidate).resolve() + ) + candidates = [] + if git_directory is not None: + candidates.append(git_directory / "agent-task-dispatcher") + state_base = Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local" / "state"))) + candidates.append(state_base / "agent-task-dispatcher" / self.workspace_id) + self.root = candidates[-1] + last_error: OSError | None = None + for candidate in candidates: + try: + candidate.mkdir(parents=True, exist_ok=True) + self.root = candidate + last_error = None + break + except OSError as exc: + last_error = exc + if last_error is not None: + raise DispatcherTerminalStateError( + f"dispatcher state 디렉터리를 만들 수 없다: {candidates}" + ) from last_error + self.path = self.root / "state.json" + self.runs = self.root / "runs" + self.runs.mkdir(exist_ok=True) + self.lock_stream = (self.root / "dispatcher.lock").open("a+", encoding="utf-8") + try: + fcntl.flock(self.lock_stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as exc: + self.lock_stream.seek(0) + owner = self.lock_stream.read().strip() or "owner metadata unavailable" + self.lock_stream.close() + raise DispatcherAlreadyRunning( + f"같은 workspace의 dispatcher가 이미 실행 중이다: " + f"{self.root}; owner={owner}" + ) from exc + try: + self.lock_stream.seek(0) + self.lock_stream.truncate() + self.lock_stream.write( + json.dumps(dispatcher_source_provenance(), ensure_ascii=False) + "\n" + ) + self.lock_stream.flush() + except OSError as exc: + self.lock_stream.close() + raise DispatcherTerminalStateError( + f"dispatcher lock owner metadata를 기록할 수 없다: {self.root}" + ) from exc + if self.path.exists(): + try: + self.data = json.loads(self.path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + self.lock_stream.close() + raise DispatcherTerminalStateError( + f"dispatcher state를 읽을 수 없다: {self.path}" + ) from exc + if not isinstance(self.data, dict): + self.lock_stream.close() + raise DispatcherTerminalStateError( + f"dispatcher state가 object가 아니다: {self.path}" + ) + else: + self.data = {"tasks": {}, "attempt_counters": {}} + try: + self._bind_workspace_identity() + self.write_claim_snapshot() + except DispatcherTerminalStateError: + self.lock_stream.close() + raise + + def _bind_workspace_identity(self) -> None: + expected = { + "id": self.workspace_id, + "root": str(self.workspace), + } + current = self.data.get("workspace_identity") + if current is None: + self.data["workspace_identity"] = expected + return + if not isinstance(current, dict): + raise DispatcherTerminalStateError( + f"dispatcher workspace identity가 object가 아니다: {self.path}" + ) + if ( + current.get("id") != expected["id"] + or current.get("root") != expected["root"] + ): + raise DispatcherTerminalStateError( + "dispatcher state의 workspace identity가 현재 checkout과 다르다: " + f"state={current} current={expected}" + ) + + def write_claim_snapshot(self) -> dict[str, dict[str, Any]]: + raw = self.data.setdefault("write_claims", {}) + if not isinstance(raw, dict): + raise DispatcherTerminalStateError( + f"dispatcher write_claims가 object가 아니다: {self.path}" + ) + snapshot: dict[str, dict[str, Any]] = {} + for owner, value in raw.items(): + if not isinstance(owner, str) or not owner or not isinstance(value, dict): + raise DispatcherTerminalStateError( + f"dispatcher write claim 형식이 유효하지 않다: owner={owner!r}" + ) + paths = value.get("paths") + exclusive = value.get("exclusive", False) + if not isinstance(paths, list) or not isinstance(exclusive, bool): + raise DispatcherTerminalStateError( + f"dispatcher write claim 경로 형식이 유효하지 않다: owner={owner}" + ) + if value.get("workspace_id") != self.workspace_id: + raise DispatcherTerminalStateError( + "dispatcher write claim의 workspace identity가 다르다: " + f"owner={owner}" + ) + canonical: list[str] = [] + for raw_path in paths: + if not isinstance(raw_path, str) or not raw_path: + raise DispatcherTerminalStateError( + f"dispatcher write claim 경로가 유효하지 않다: owner={owner}" + ) + path = Path(raw_path) + resolved = path.resolve() + try: + resolved.relative_to(self.workspace) + except ValueError as exc: + raise DispatcherTerminalStateError( + "dispatcher write claim이 workspace 밖을 가리킨다: " + f"owner={owner} path={raw_path}" + ) from exc + if not path.is_absolute() or str(resolved) != raw_path or resolved == self.workspace: + raise DispatcherTerminalStateError( + "dispatcher write claim 경로가 canonical file이 아니다: " + f"owner={owner} path={raw_path}" + ) + canonical.append(raw_path) + if (not canonical and not exclusive) or len(canonical) != len(set(canonical)): + raise DispatcherTerminalStateError( + f"dispatcher write claim 경로 집합이 유효하지 않다: owner={owner}" + ) + record = dict(value) + record["paths"] = sorted(canonical) + snapshot[owner] = record + return snapshot + + def replace_write_claims( + self, + claims: dict[str, dict[str, Any]], + *, + persist: bool, + ) -> None: + previous = self.data.get("write_claims", {}) + self.data["write_claims"] = claims + try: + self.write_claim_snapshot() + if persist and previous != claims: + self.save() + except BaseException: + self.data["write_claims"] = previous + raise + + def adopt_active_write_claim(self, task: Task) -> None: + claims = self.write_claim_snapshot() + if task.name in claims: + return + timestamp = now_iso() + claims[task.name] = { + "task": task.name, + "plan_hash": task.plan_hash, + "paths": sorted(task.write_set) if task.write_set_known else [], + "exclusive": not task.write_set_known, + "workspace_id": self.workspace_id, + "acquired_at": timestamp, + "updated_at": timestamp, + "source": "active-recovery", + } + self.replace_write_claims(claims, persist=True) + + def release_write_claim(self, task_name: str, *, persist: bool = True) -> bool: + claims = self.write_claim_snapshot() + if task_name not in claims: + return False + del claims[task_name] + self.replace_write_claims(claims, persist=persist) + return True + + def save(self) -> None: + write_json(self.path, self.data) + + def close(self) -> None: + if not self.lock_stream.closed: + self.lock_stream.close() + + def task_state(self, task: Task) -> dict[str, Any]: + tasks = self.data.setdefault("tasks", {}) + current = tasks.get(task.name) + if not current or current.get("plan_hash") != task.plan_hash: + current = { + "plan_hash": task.plan_hash, + "worker_done": False, + "worker_cli": None, + "worker_model": None, + "selfcheck_done": False, + "blocked": None, + "active_stage": None, + "active_locator": None, + "review_no_progress": 0, + "selfcheck_incomplete": 0, + "selfcheck_context_locator": None, + "recovery_failures": {}, + "execution_decisions": {}, + "route_transition_history": [], + "stage_failure_budgets": {}, + "retry_failover_pending": False, + "retry_failover_context": None, + "blocker_evidence": None, + } + tasks[task.name] = current + self.save() + return current + + def peek_task_state(self, task: Task) -> dict[str, Any]: + current = self.data.get("tasks", {}).get(task.name) + if current and current.get("plan_hash") == task.plan_hash: + return dict(current) + return { + "plan_hash": task.plan_hash, + "worker_done": False, + "worker_cli": None, + "worker_model": None, + "selfcheck_done": False, + "blocked": None, + "active_stage": None, + "active_locator": None, + "review_no_progress": 0, + "selfcheck_incomplete": 0, + "selfcheck_context_locator": None, + "recovery_failures": {}, + "execution_decisions": {}, + "route_transition_history": [], + "retry_failover_pending": False, + "retry_failover_context": None, + "blocker_evidence": None, + } + + def update_task(self, task: Task, **values: Any) -> None: + state = self.task_state(task) + state.update(values) + self.save() + + def mark_active(self, task: Task, stage: str, locator: Path | None = None) -> None: + self.update_task( + task, + active_stage=stage, + active_locator=str(locator) if locator else None, + active_started_at=now_iso(), + ) + + def clear_active(self, task: Task) -> None: + self.update_task( + task, + active_stage=None, + active_locator=None, + active_started_at=None, + ) + + def consume_matching_retry_handoff(self, task: Task, locator_path: str) -> bool: + """Atomically consume a pending retry handoff when a matching locator exists. + + When a worker writes its locator and sets active_locator, the pending + retry-failover state must be cleared in the same transaction. + This prevents a crash window where a restart sees the pending handoff + and creates a duplicate invocation. + + Returns True if the pending handoff was consumed, False if no matching + locator was found (active_locator is None or differs from locator_path). + """ + state = self.task_state(task) + active = state.get("active_locator") + if active != locator_path: + return False + pending = state.get("retry_failover_pending") + if not pending: + return False + context = state.get("retry_failover_context") + if not isinstance(context, dict): + return False + context_locator = context.get("locator") + if context_locator != locator_path: + return False + # Snapshot current state to restore on save failure. This ensures the + # crash window is not widened by a partial consume: if the save fails, + # the pending handoff remains intact both in-memory and on-disk. + pre_state = dict(state) + pre_keys = set(state.keys()) + pre_values = {k: state.get(k) for k in ["retry_failover_pending", "retry_failover_context"]} + try: + self.update_task( + task, + retry_failover_pending=False, + retry_failover_context=None, + ) + except Exception: + # Restore the pre-consume state on any failure. + for k, v in pre_values.items(): + state[k] = v + # Restore key existence: if a key existed before, restore its value; + # if a key did not exist before, ensure it is not present. + for k in list(state.keys()): + if k not in pre_keys: + del state[k] + for k, v in pre_values.items(): + if k not in state: + state[k] = v + raise + return True + + def commit_retry_handoff_locator( + self, task: Task, handoff_id: str, locator_path: str, + ) -> bool: + """Atomically commit a new locator and consume a matching pending retry handoff. + + This is the durable one-save transition for retry handoff. It matches + the pending handoff by stable handoff_id (not by locator path, which + changes on each attempt) and atomically updates active_locator, clears + the pending flag, and clears the context in a single save. + + On save failure the pre-state is fully restored both in-memory and on + disk so the crash window is not widened. + + Returns True if a matching pending handoff was consumed, False if no + pending handoff with the given handoff_id was found. + """ + state = self.task_state(task) + pending = state.get("retry_failover_pending") + if not pending: + return False + context = state.get("retry_failover_context") + if not isinstance(context, dict): + return False + if context.get("handoff_id") != handoff_id: + return False + # Snapshot current state to restore on save failure. + pre_state = dict(state) + pre_keys = set(state.keys()) + pre_values = { + k: state.get(k) + for k in [ + "retry_failover_pending", + "retry_failover_context", + "active_locator", + ] + } + try: + self.update_task( + task, + active_locator=locator_path, + retry_failover_pending=False, + retry_failover_context=None, + ) + except Exception: + for k, v in pre_values.items(): + state[k] = v + for k in list(state.keys()): + if k not in pre_keys: + del state[k] + for k, v in pre_values.items(): + if k not in state: + state[k] = v + raise + return True + + def next_attempt(self, task: Task, role: str) -> int: + key = f"{task.name}|{task.plan_hash}|{role}" + counters = self.data.setdefault("attempt_counters", {}) + number = int(counters.get(key, 0)) + counters[key] = number + 1 + self.save() + return number + + def clear_blocked(self, task_group: str | None = None) -> None: + prefix = f"{task_group}/" if task_group else None + for task_name, value in self.data.get("tasks", {}).items(): + if ( + task_group is not None + and task_name != task_group + and not task_name.startswith(prefix) + ): + continue + value["blocked"] = None + value["review_no_progress"] = 0 + value["selfcheck_incomplete"] = 0 + value["selfcheck_context_locator"] = None + value["recovery_failures"] = {} + value["stage_failure_budgets"] = {} + value["retry_failover_pending"] = False + self.save() + + def mark_retry_failover(self, task_group: str | None = None, workspace: Path | None = None) -> None: + prefix = f"{task_group}/" if task_group else None + for task_name, value in self.data.get("tasks", {}).items(): + if ( + task_group is not None + and task_name != task_group + and not task_name.startswith(prefix) + ): + continue + if not value.get("blocked"): + continue + blocker_evidence = value.get("blocker_evidence") if isinstance(value.get("blocker_evidence"), dict) else {} + decisions = value.get("execution_decisions", {}) + worker_decision = decisions.get("worker") if isinstance(decisions, dict) else None + role = blocker_evidence.get("role") + failure_class = blocker_evidence.get("failure_class") + locator = blocker_evidence.get("locator") + selected = blocker_evidence.get("selected") + work_unit_id = blocker_evidence.get("work_unit_id") + qualified = ( + role == "worker" + and failure_class in QUALIFIED_FAILOVER_FAILURES + and isinstance(locator, str) + and locator.strip() + and isinstance(selected, dict) + and isinstance(work_unit_id, str) + and isinstance(worker_decision, dict) + and worker_decision.get("work_unit_id") == work_unit_id + ) + handoff_id = str(uuid.uuid4()) + retry_context = ({ + "role": role, + "failure_class": failure_class, + "locator": locator, + "selected": selected, + "work_unit_id": work_unit_id, + "handoff_id": handoff_id, + } if qualified else None) + + value["blocked"] = None + value["review_no_progress"] = 0 + value["selfcheck_incomplete"] = 0 + value["selfcheck_context_locator"] = None + value["recovery_failures"] = {} + value["stage_failure_budgets"] = {} + value["retry_failover_pending"] = qualified + value["retry_failover_context"] = retry_context + value["blocker_evidence"] = None + self.save() + + + def prepare_orchestration( + self, + scope: str, + tasks: list[Task], + workspace: Path, + ) -> None: + orchestrations = self.data.setdefault("orchestrations", {}) + current = orchestrations.get(scope) + if current is None or (current.get("status") == "complete" and tasks): + current = {"status": "running", "tasks": {}} + orchestrations[scope] = current + changed = False + tracked = current.setdefault("tasks", {}) + for task in tasks: + record = tracked.get(task.name) + if record is None: + tracked[task.name] = { + "status": "active", + "archive": None, + "archive_baseline": [ + str(path.resolve()) + for path in matching_archive_directories_by_name( + workspace, + task.name, + require_complete=False, + ) + ], + } + changed = True + continue + if record.get("status") != "complete" and ( + record.get("status") != "active" or "reason" in record + ): + record["status"] = "active" + record.pop("reason", None) + changed = True + if changed or current.get("status") != "running": + current["status"] = "running" + self.save() + + def mark_orchestration_task_complete( + self, + scope: str, + task_name: str, + archive: str | Path, + ) -> None: + archive_path = Path(archive).resolve() + if not archive_path.is_dir() or not (archive_path / "complete.log").is_file(): + raise RuntimeError( + f"완료 archive에 complete.log가 없다: task={task_name} archive={archive_path}" + ) + current = self.data.setdefault("orchestrations", {}).setdefault( + scope, {"status": "running", "tasks": {}} + ) + tracked = current.setdefault("tasks", {}) + record = tracked.setdefault( + task_name, + {"status": "active", "archive": None, "archive_baseline": []}, + ) + record.update(status="complete", archive=str(archive_path)) + record.pop("reason", None) + self.release_write_claim(task_name, persist=False) + self.save() + cleanup_completed_task_attempt_logs(self.runs, task_name) + + def mark_orchestration_blocked( + self, + scope: str, + outcomes: dict[str, tuple[str, str]], + ) -> None: + current = self.data.setdefault("orchestrations", {}).setdefault( + scope, {"status": "running", "tasks": {}} + ) + current["status"] = "blocked" + tracked = current.setdefault("tasks", {}) + for task_name, (status, reason) in outcomes.items(): + record = tracked.setdefault( + task_name, + { + "status": "active", + "archive": None, + "archive_baseline": [], + }, + ) + if record.get("status") == "complete": + continue + record.update(status=status, reason=reason) + self.save() + + def reconcile_orchestration( + self, + scope: str, + workspace: Path, + active_or_running: set[str], + ) -> tuple[dict[str, str], dict[str, str]]: + current = self.data.setdefault("orchestrations", {}).setdefault( + scope, {"status": "running", "tasks": {}} + ) + completed: dict[str, str] = {} + errors: dict[str, str] = {} + changed = False + for task_name, record in current.setdefault("tasks", {}).items(): + if record.get("status") == "complete": + archive = str(record.get("archive") or "") + if archive and (Path(archive) / "complete.log").is_file(): + completed[task_name] = archive + if task_name not in active_or_running: + changed = ( + self.release_write_claim(task_name, persist=False) + or changed + ) + else: + errors[task_name] = "persisted complete archive가 유효하지 않다" + continue + if task_name in active_or_running: + continue + baseline = set(str(path) for path in record.get("archive_baseline", [])) + candidates = [ + path + for path in matching_archive_directories_by_name(workspace, task_name) + if str(path.resolve()) not in baseline + ] + if len(candidates) == 1: + archive = str(candidates[0].resolve()) + record.update(status="complete", archive=archive) + completed[task_name] = archive + changed = self.release_write_claim(task_name, persist=False) or changed + changed = True + elif not candidates: + errors[task_name] = ( + "관찰된 task가 active와 새 complete.log archive 모두에서 사라졌다" + ) + else: + errors[task_name] = ( + "새 complete.log archive가 여러 개라 완료 경로를 확정할 수 없다: " + + ",".join(str(path) for path in candidates) + ) + if changed: + self.save() + for task_name in completed: + if task_name not in active_or_running: + cleanup_completed_task_attempt_logs(self.runs, task_name) + return completed, errors + + def orchestration_tasks(self, scope: str) -> set[str]: + current = self.data.get("orchestrations", {}).get(scope, {}) + return set(current.get("tasks", {})) + + def mark_orchestration_complete(self, scope: str) -> None: + current = self.data.setdefault("orchestrations", {}).setdefault( + scope, {"status": "running", "tasks": {}} + ) + current["status"] = "complete" + self.save() + + +def orchestration_live_agent_processes( + store: StateStore, + scope: str, +) -> dict[str, str]: + """Return observed tasks with live or conservatively active evidence.""" + task_states = store.data.get("tasks", {}) + live: dict[str, str] = {} + for task_name in store.orchestration_tasks(scope): + state = task_states.get(task_name) + if not isinstance(state, dict): + continue + is_live, detail = external_active_is_live( + state, + expected_workspace=store.workspace, + expected_workspace_id=store.workspace_id, + expected_runs_root=store.runs, + ) + if is_live: + live[task_name] = detail + return live + + +def workspace_live_agent_processes( + store: StateStore, +) -> dict[str, str]: + """Return observed tasks across the entire physical workspace with live or conservatively active evidence.""" + task_states = store.data.get("tasks", {}) + live: dict[str, str] = {} + if isinstance(task_states, dict): + for task_name, state in task_states.items(): + if not isinstance(state, dict): + continue + is_live, detail = external_active_is_live( + state, + expected_workspace=store.workspace, + expected_workspace_id=store.workspace_id, + expected_runs_root=store.runs, + ) + if is_live: + live[task_name] = detail + return live + + +def parse_route(plan: Path | None) -> tuple[str | None, int | None]: + if plan is None: + return None, None + match = PLAN_RE.match(plan.name) + if not match: + return None, None + return match.group(1), int(match.group(2)) + + +def parse_task_name(task_root: Path, directory: Path) -> str: + return directory.relative_to(task_root).as_posix() + + +def inspect_write_set( + plan: Path | None, + workspace: Path, +) -> tuple[set[str], list[str]]: + if plan is None: + return set(), ["PLAN 경로가 없다"] + if not plan.is_file(): + return set(), [f"PLAN 파일이 없다: {plan}"] + workspace = workspace.resolve() + try: + text = plan.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + return set(), [f"PLAN 파일을 읽을 수 없다: {plan}: {exc}"] + matches = [] + for heading in MODIFIED_FILES_HEADINGS: + pattern = rf"^##\s*{re.escape(heading)}[ \t]*$([\s\S]*?)(?=^##\s|\Z)" + for m in re.finditer(pattern, text, re.MULTILINE): + matches.append(m) + if not matches: + return set(), ["Modified Files Summary 섹션이 없다"] + if len(matches) != 1: + return set(), [ + f"Modified Files Summary 섹션은 정확히 1개여야 한다: count={len(matches)}" + ] + match = matches[0] + result: set[str] = set() + diagnostics: list[str] = [] + for line in match.group(1).splitlines(): + if not line.lstrip().startswith("|"): + continue + cells = [cell.strip() for cell in line.strip().strip("|").split("|")] + if not cells: + continue + if all(set(cell) <= {":", "-"} for cell in cells): + continue + if cells[0].casefold() in MODIFIED_FILES_HEADER_CELLS: + continue + claims = re.findall(r"`([^`]+)`", cells[0]) + if not claims: + diagnostics.append( + f"정확한 backtick workspace 파일 경로가 없는 claim 행: {cells[0]}" + ) + continue + for value in claims: + normalized = re.sub(r":\d+(?::\d+)?$", "", value.strip()) + if not normalized: + diagnostics.append("빈 경로 claim은 허용되지 않는다") + continue + if PLACEHOLDER_PATH_RE.search(normalized): + diagnostics.append( + f"placeholder 또는 malformed path claim은 허용되지 않는다: {normalized}" + ) + continue + if normalized.startswith(("http://", "https://")): + diagnostics.append(f"URL claim은 허용되지 않는다: {normalized}") + continue + if "\\" in normalized: + diagnostics.append( + f"malformed path claim은 허용되지 않는다: {normalized}" + ) + continue + if any(character in normalized for character in "*?[]"): + diagnostics.append( + f"glob 또는 broad path claim은 허용되지 않는다: {normalized}" + ) + continue + if normalized.endswith(("/", "\\")): + diagnostics.append( + f"디렉터리 claim은 허용되지 않는다: {normalized}" + ) + continue + candidate = Path(normalized) + try: + resolved = ( + candidate.resolve() + if candidate.is_absolute() + else (workspace / candidate).resolve() + ) + except (OSError, RuntimeError) as exc: + diagnostics.append( + f"경로를 canonicalize할 수 없다: {normalized}: {exc}" + ) + continue + try: + resolved.relative_to(workspace) + except ValueError: + diagnostics.append( + f"workspace 밖 claim은 허용되지 않는다: {normalized}" + ) + continue + if resolved == workspace: + diagnostics.append("workspace root claim은 허용되지 않는다") + continue + if resolved.is_dir(): + diagnostics.append( + f"디렉터리 claim은 허용되지 않는다: {normalized}" + ) + continue + result.add(str(resolved)) + if not result: + diagnostics.append("정확한 workspace 파일 claim이 하나 이상 필요하다") + return result, diagnostics + + +def extract_write_set(plan: Path | None, workspace: Path) -> tuple[set[str], bool]: + write_set, diagnostics = inspect_write_set(plan, workspace) + if diagnostics: + return set(), False + return write_set, True + + +def latest_verdict_log(directory: Path) -> Path | None: + candidates: list[tuple[int, Path]] = [] + for path in directory.glob("code_review_*.log"): + match = REVIEW_LOG_RE.fullmatch(path.name) + if match is None or not path.is_file() or read_verdict(path) is None: + continue + candidates.append((int(match.group(3)), path)) + if not candidates: + return None + return max( + candidates, + key=lambda candidate: (candidate[0], candidate[1].name), + )[1] + + +def matching_plan_log(directory: Path, review_log: Path | None) -> Path | None: + if review_log is None: + return None + review_identity = plan_identity(review_log) + matches = [ + path + for path in directory.glob("plan_*.log") + if PLAN_LOG_RE.fullmatch(path.name) is not None + and path.is_file() + and plan_identity(path) == review_identity + ] + return max(matches, key=lambda path: path.stat().st_mtime_ns) if matches else None + + +def read_task_directory(workspace: Path, directory: Path) -> Task | None: + """Read one already-known task directory without scanning the task group.""" + task_root = workspace / "agent-task" + if not directory.is_dir(): + return None + plans = sorted(p for p in directory.iterdir() if p.is_file() and PLAN_RE.match(p.name)) + reviews = sorted(p for p in directory.iterdir() if p.is_file() and REVIEW_RE.match(p.name)) + users = sorted(directory.glob("USER_REVIEW.md")) + complete = directory / "complete.log" + recovery_log = latest_verdict_log(directory) + if not plans and not reviews and not users and not complete.exists() and recovery_log is None: + return None + name = parse_task_name(task_root, directory) + errors: list[str] = [] + if len(plans) > 1: + errors.append(f"active PLAN이 {len(plans)}개다") + if len(reviews) > 1: + errors.append(f"active CODE_REVIEW가 {len(reviews)}개다") + if len(users) > 1: + errors.append(f"USER_REVIEW가 {len(users)}개다") + if users and (plans or reviews): + errors.append("USER_REVIEW stop state와 active PLAN/CODE_REVIEW가 공존한다") + plan = plans[0] if len(plans) == 1 else None + review = reviews[0] if len(reviews) == 1 else None + recovery = complete.exists() or recovery_log is not None + if bool(plan) != bool(review) and not recovery: + errors.append("active PLAN/CODE_REVIEW pair가 불완전하다") + relative = directory.relative_to(task_root) + subtask = relative.parts[1] if len(relative.parts) == 2 else None + index = 0 + deps: tuple[str, ...] = () + if subtask: + match = SUBTASK_RE.match(subtask) + if match: + index = int(match.group("index")) + deps = tuple((match.group("deps") or "").split(",")) if match.group("deps") else () + else: + errors.append(f"split subtask 이름이 계약과 다르다: {subtask}") + lane, grade = parse_route(plan) + recovery_plan = matching_plan_log(directory, recovery_log) + write_set_source = plan or recovery_plan + write_set: set[str] = set() + write_set_known = False + if recovery_log is not None and recovery_plan is None: + errors.append( + "PLAN Modified Files Summary를 복구할 matching PLAN log가 없다" + ) + elif write_set_source is not None: + write_set, write_set_diagnostics = inspect_write_set( + write_set_source, + workspace, + ) + write_set_known = bool(write_set) and not write_set_diagnostics + errors.extend( + f"PLAN Modified Files Summary가 유효하지 않다: {diagnostic}" + for diagnostic in write_set_diagnostics + ) + if plan is not None: + metadata = PLAN_IDENTITY_RE.search( + plan.read_text(encoding="utf-8", errors="replace")[:1024] + ) + if metadata is None: + errors.append("PLAN 첫 줄 generation metadata를 판별할 수 없다") + elif metadata.group("task") != name: + errors.append( + f"PLAN task metadata가 디렉터리와 다르다: {metadata.group('task')}" + ) + errors.extend(validate_plan_metadata(plan, workspace)) + if review is not None and plan_identity(plan) != plan_identity(review): + errors.append("PLAN/CODE_REVIEW generation metadata가 다르다") + return Task( + name=name, + directory=directory, + plan=plan, + review=review, + user_review=users[0] if len(users) == 1 else None, + recovery=recovery, + errors=errors, + index=index, + deps=deps, + write_set=write_set, + write_set_known=write_set_known, + plan_hash=( + plan_identity(plan) + if plan + else sha256_file( + recovery_log + or (users[0] if len(users) == 1 else complete) + ) + ), + lane=lane, + grade=grade, + ) + + + + +@dataclass +class StageFailureBudget: + """Persistent failure counter shared by one work unit and stage.""" + + store: StateStore + task: Task + work_unit_id: str + stage: str + + + @classmethod + def from_decision(cls, store: StateStore, task: Task, decision: dict[str, Any]) -> "StageFailureBudget": + work_unit_id = decision.get("work_unit_id") + stage = decision.get("stage") + if not isinstance(work_unit_id, str) or not work_unit_id or not isinstance(stage, str) or not stage: + raise ExecutionDecisionError("stage failure budget identity가 유효하지 않다") + return cls(store, task, work_unit_id, stage) + + + @property + def key(self) -> str: + return f"{self.work_unit_id}|{self.stage}" + + def _budgets(self) -> dict[str, Any]: + state = self.store.task_state(self.task) + budgets = state.get("stage_failure_budgets", {}) + if not isinstance(budgets, dict): + raise ExecutionDecisionError("persisted stage failure budgets schema가 유효하지 않다") + return dict(budgets) + + def count(self) -> int: + entry = self._budgets().get(self.key, {}) + if not isinstance(entry, dict): + raise ExecutionDecisionError("persisted stage failure budget entry가 유효하지 않다") + return int(entry.get("count", 0)) + + def record_failure(self, *, target: dict[str, Any], transition: str) -> int: + budgets = self._budgets() + entry = dict(budgets.get(self.key, {})) + count = int(entry.get("count", 0)) + 1 + entry.update( + work_unit_id=self.work_unit_id, stage=self.stage, count=count, + last_target={ + "target_id": target.get("target_id"), + "agent": target.get("agent"), + "model": target.get("model"), + }, + last_transition=transition, + ) + budgets[self.key] = entry + self.store.update_task(self.task, stage_failure_budgets=budgets) + return count + + def reset_on_success(self) -> None: + budgets = self._budgets() + budgets.pop(self.key, None) + self.store.update_task(self.task, stage_failure_budgets=budgets) + + +def scan_tasks( + workspace: Path, + task_group: str | None, + *, + exclude_names: set[str] | None = None, +) -> list[Task]: + task_root = workspace / "agent-task" + if not task_root.is_dir(): + raise DispatcherTerminalStateError( + f"agent-task 디렉터리가 없다: {task_root}" + ) + directories: list[Path] = [] + try: + groups = [task_root / task_group] if task_group else sorted( + p for p in task_root.iterdir() if p.is_dir() and p.name != "archive" + ) + except FileNotFoundError: + return [] + for group in groups: + if not group.is_dir(): + continue + directories.append(group) + try: + directories.extend(sorted(p for p in group.iterdir() if p.is_dir())) + except FileNotFoundError: + continue + tasks = [ + task + for directory in directories + if ( + exclude_names is None + or parse_task_name(task_root, directory) not in exclude_names + ) + if (task := read_task_directory(workspace, directory)) is not None + ] + return sorted(tasks, key=lambda task: (task.index, task.name)) + + +def dependency_candidates(workspace: Path, task: Task, predecessor: str) -> list[Path]: + parts = task.name.split("/") + if len(parts) != 2: + return [] + group = parts[0] + task_root = workspace / "agent-task" + found: list[Path] = [] + active_group = task_root / group + for pattern in (f"{predecessor}_*/complete.log", f"{predecessor}+*/complete.log"): + found.extend(active_group.glob(pattern)) + archive = task_root / "archive" + if archive.is_dir(): + try: + years = list(archive.iterdir()) + except FileNotFoundError: + years = [] + for year in years: + if not year.is_dir(): + continue + try: + months = list(year.iterdir()) + except FileNotFoundError: + continue + for month in months: + archived_group = month / group + if not archived_group.is_dir(): + continue + for pattern in (f"{predecessor}_*/complete.log", f"{predecessor}+*/complete.log"): + found.extend(archived_group.glob(pattern)) + return sorted(set(path.resolve() for path in found)) + + +def dependency_state(workspace: Path, task: Task) -> tuple[bool, str]: + missing: list[str] = [] + ambiguous: list[str] = [] + for predecessor in task.deps: + candidates = dependency_candidates(workspace, task, predecessor) + if not candidates: + missing.append(predecessor) + elif len(candidates) > 1: + ambiguous.append(f"{predecessor}={','.join(str(p) for p in candidates)}") + if ambiguous: + return False, "dependency ambiguity: " + "; ".join(ambiguous) + if missing: + return False, "predecessor complete.log 대기: " + ",".join(missing) + return True, "ready" + + +def live_predecessors( + task: Task, + active_task_names: set[str], +) -> list[str]: + parts = task.name.split("/") + if len(parts) != 2 or not task.deps: + return [] + group = parts[0] + live: list[str] = [] + for predecessor in task.deps: + prefix = re.compile(rf"^{re.escape(predecessor)}(?:[+_])") + if any( + name.startswith(f"{group}/") + and prefix.match(name.split("/", 1)[1]) + for name in active_task_names + ): + live.append(predecessor) + return live + + +def _selector_module(): + if "agent_task_execution_target_selector" in sys.modules: + return sys.modules["agent_task_execution_target_selector"] + path = Path(__file__).resolve().parent / "select_execution_target.py" + spec = importlib.util.spec_from_file_location("agent_task_execution_target_selector", path) + if spec is None or spec.loader is None: + raise ExecutionDecisionError(f"selector load 실패: {path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _decision_file(task: Task, stage: str) -> Path: + path = task.plan if stage == "worker" else task.review + if path is None or not path.is_file(): + raise ExecutionDecisionError(f"{stage} selector 입력 파일이 없다") + return path + + +def agent_spec_from_decision(decision: dict[str, Any]) -> AgentSpec: + try: + selector = _selector_module() + selector._validate_prior_decision(decision) + selected = decision["selected"] + catalog_evidence = decision["catalog"] + catalog = selector.load_runtime_catalog(catalog_evidence["source"]) + if catalog.revision != catalog_evidence["revision"]: + raise ExecutionDecisionError( + "실행 카탈로그가 target 선택 이후 변경됐다" + ) + target = selector.policy.canonical_target( + catalog, selected["target_id"] + ) + if target is None or selector._target_snapshot(target) != selected: + raise ExecutionDecisionError( + "selector selected가 주입된 카탈로그 target과 일치하지 않는다" + ) + except ExecutionDecisionError: + raise + except Exception as exc: + raise ExecutionDecisionError( + f"selector catalog validation 실패: {exc}" + ) from exc + runtime = dict(target.runtime) + return AgentSpec( + target.agent, + target.model, + f"{target.agent}/{target.model}", + native_resume=bool(runtime.get("native_session_monitor")), + target_id=target.catalog_id, + execution_class=target.execution_class, + selfcheck_required=target.selfcheck_required, + runtime=runtime, + ) + + +def _spec_from_completing_decision(decision: dict[str, Any]) -> AgentSpec: + """Lightweight AgentSpec extraction from a persisted completing decision. + + Unlike `agent_spec_from_decision`, this does not re-validate against the + selector policy. The completing decision is already authoritative evidence + of the target that succeeded, so re-running policy is unnecessary and would + defeat the purpose of pinning the selfcheck target. + """ + return agent_spec_from_decision(decision) + + +def select_execution_decision( + task: Task, *, stage: str, prior_decision: dict[str, Any] | None = None, + evaluated_at: datetime | None = None, + transition: str | None = None, + failure_class: str | None = None, +) -> dict[str, Any]: + try: + selector = _selector_module() + except Exception as exc: + code = getattr(exc, "code", exc.__class__.__name__) + raise ExecutionDecisionError( + f"{stage} selector load 실패 [{code}]: {exc}" + ) from exc + try: + if transition is None: + if prior_decision is not None and stage == "worker" and task.plan and task.plan.is_file(): + current_id = work_unit_id_from_file(task.plan) + prior_id = prior_decision.get("work_unit_id") if isinstance(prior_decision, dict) else None + if current_id and isinstance(prior_id, str) and prior_id and prior_id != current_id: + prior_decision = None + transition = "resume" if prior_decision is not None else "initial" + return selector.select_execution_target( + _decision_file(task, stage), stage=stage, + evaluated_at=evaluated_at or datetime.now(UTC), + catalog_path=EXECUTION_CATALOG_PATH, + transition=transition, + prior_decision=prior_decision, + failure_class=failure_class, + ) + except (OSError, ValueError, selector.SelectorInputError) as exc: + code = getattr(exc, "code", exc.__class__.__name__) + raise ExecutionDecisionError( + f"{stage} selector decision 실패 [{code}]: {exc}" + ) from exc + + +def work_unit_id_from_file(path: Path) -> str | None: + if not path.is_file(): + return None + try: + head = path.read_text(encoding="utf-8", errors="replace")[:1024] + match = PLAN_IDENTITY_RE.search(head) + if match: + return metadata_work_unit_id(match) + except Exception: + pass + return None + + +def official_review_plan_source(task: Task) -> Path: + """Resolve the authoritative PLAN generation for an official review.""" + if task.plan is not None or task.review is not None: + if task.plan is None or task.review is None: + raise ExecutionDecisionError( + "official review active PLAN/CODE_REVIEW pair가 불완전하다" + ) + return task.plan + recovery_log = latest_verdict_log(task.directory) + recovery_plan = matching_plan_log(task.directory, recovery_log) + if recovery_log is None or recovery_plan is None: + raise ExecutionDecisionError( + "official review recovery의 matching archived PLAN identity를 복구할 수 없다" + ) + return recovery_plan + + +def official_review_source_identity(task: Task) -> tuple[str, int, str]: + source = official_review_plan_source(task) + route_match = PLAN_RE.match(source.name) or PLAN_LOG_RE.match(source.name) + if route_match is None: + raise ExecutionDecisionError( + f"official review PLAN route를 복구할 수 없다: {source.name}" + ) + try: + head = source.read_text(encoding="utf-8", errors="replace")[:1024] + except OSError as exc: + raise ExecutionDecisionError( + f"official review PLAN source를 읽을 수 없다: {source}" + ) from exc + metadata = PLAN_IDENTITY_RE.search(head) + if metadata is None or metadata.group("task") != task.name: + raise ExecutionDecisionError( + f"official review PLAN work-unit identity를 복구할 수 없다: {source}" + ) + work_unit_id = metadata_work_unit_id(metadata) + return route_match.group(1), int(route_match.group(2)), work_unit_id + + +def synthesized_official_review_decision( + task: Task, *, evaluated_at: datetime | None = None +) -> dict[str, Any]: + lane, grade, work_unit_id = official_review_source_identity(task) + evaluated = evaluated_at or datetime.now(UTC) + if evaluated.tzinfo is None or evaluated.utcoffset() is None: + raise ExecutionDecisionError( + "official review evaluated_at이 timezone-aware가 아니다" + ) + selector = _selector_module() + initial = selector.select_execution_target_for_route( + work_unit_id=work_unit_id, + stage="review", + lane=lane, + grade=grade, + evaluated_at=evaluated, + catalog_path=EXECUTION_CATALOG_PATH, + ) + if task.plan is None and task.review is None: + return selector.select_execution_target_for_route( + work_unit_id=work_unit_id, + stage="review", + lane=lane, + grade=grade, + evaluated_at=evaluated, + catalog_path=EXECUTION_CATALOG_PATH, + transition="resume", + prior_decision=initial, + ) + return initial + + +def read_or_preview_stage_decision( + task: Task, + state: dict[str, Any], + *, + stage: str, + dry_run: bool = False, + evaluated_at: datetime | None = None, +) -> dict[str, Any]: + decisions = state.get("execution_decisions", {}) if isinstance(state, dict) else {} + prior = decisions.get(stage) if isinstance(decisions, dict) else None + + if stage == "review": + lane, grade, work_unit_id = official_review_source_identity(task) + if ( + isinstance(prior, dict) + and isinstance(prior.get("decision"), dict) + ): + if ( + prior.get("work_unit_id") != work_unit_id + or prior.get("stage") != "review" + or prior.get("lane") != lane + or prior.get("grade") != grade + ): + raise ExecutionDecisionError( + "persisted official review decision이 recovery source identity/route와 다르다" + ) + agent_spec_from_decision(prior) + return prior + return synthesized_official_review_decision( + task, evaluated_at=evaluated_at + ) + + if isinstance(prior, dict) and task.plan and task.plan.is_file(): + work_unit_id = work_unit_id_from_file(task.plan) + if work_unit_id and prior.get("work_unit_id") == work_unit_id: + return prior + + return select_execution_decision( + task, + stage=stage, + prior_decision=prior, + evaluated_at=evaluated_at, + ) + + +def selector_evidence_lines(decision: dict[str, Any] | None) -> list[str]: + if not isinstance(decision, dict): + return [] + selected = decision.get("selected", {}) + if not isinstance(selected, dict): + return [] + work_unit = decision.get("work_unit_id", "none") + decision_info = decision.get("decision", {}) + if not isinstance(decision_info, dict): + decision_info = {} + rule_id = decision_info.get("rule_id", decision.get("rule_id", "none")) + priority = decision_info.get( + "policy_priority", decision.get("priority", "none") + ) + transition = decision.get("transition", {}) + trigger = transition.get("trigger", "none") if isinstance(transition, dict) else "none" + candidates = decision.get("candidates", []) + cand_strs = [] + if isinstance(candidates, list): + for c in candidates: + if isinstance(c, dict): + rank = c.get("candidate_rank", "?") + agent = c.get("agent", "?") + model = c.get("model", "?") + cand_strs.append(f"#{rank}:{agent}/{model}") + + reasons = decision_info.get( + "reason_codes", selected.get("reason_codes", []) + ) + reason_str = ",".join(reasons) if isinstance(reasons, list) else str(reasons) + + lines = [ + f"work_unit_id={work_unit}", + f"rule_id={rule_id}", + f"priority={priority}", + f"transition={trigger}", + ] + if cand_strs: + lines.append(f"candidates={';'.join(cand_strs)}") + if reason_str: + lines.append(f"reason_codes={reason_str}") + return lines + + +def selector_runtime_evidence(decision: dict[str, Any]) -> dict[str, Any]: + """Return canonical selector fields persisted in runtime audit records.""" + return { + "work_unit_id": decision.get("work_unit_id"), + "candidates": decision.get("candidates"), + "selected": decision.get("selected"), + "decision": decision.get("decision"), + "catalog": decision.get("catalog"), + "transition": decision.get("transition"), + } + + +def commit_execution_decision( + store: StateStore, task: Task, stage: str, decision: dict[str, Any], +) -> None: + state = store.task_state(task) + decisions, history = state.get("execution_decisions", {}), state.get("route_transition_history", []) + if not isinstance(decisions, dict) or not isinstance(history, list): + raise ExecutionDecisionError("persisted selector state schema가 유효하지 않다") + decisions = dict(decisions) + decisions[stage] = decision + + stage_budget_count = 0 + try: + stage_budget_count = StageFailureBudget.from_decision(store, task, decision).count() + except Exception: + pass + + selected = decision.get("selected", {}) + history_entry = { + "stage": stage, + "transition": decision.get("transition", {}).get("trigger") if isinstance(decision.get("transition"), dict) else None, + "work_unit_id": decision.get("work_unit_id"), + "candidates": decision.get("candidates"), + "selected": selected, + "decision": decision.get("decision"), + "reason_codes": decision.get("decision", {}).get("reason_codes", []) + if isinstance(decision.get("decision"), dict) + else [], + "catalog": decision.get("catalog"), + "stage_budget": stage_budget_count, + } + history = [*history, history_entry] + # Preserve retry handoff state whenever a pending retry is in flight. + # The retry handoff (stable handoff_id, pending flag, context) must survive + # the decision commit so that the subsequent production invoke() can read + # it, embed the handoff_id in the new locator record, and atomically + # consume the pending handoff via commit_retry_handoff_locator(). + # Clearing it here would force invoke() to fall back to a generic + # active-locator update and lose the crash-safe handoff identity. + # invoke() handles consumption regardless of failover or resume transition. + is_retry_in_flight = bool(state.get("retry_failover_pending")) + update_kwargs = { + "execution_decisions": decisions, + "route_transition_history": history, + "blocked": None, + "blocker_evidence": None, + } + if not is_retry_in_flight: + update_kwargs["retry_failover_pending"] = False + update_kwargs["retry_failover_context"] = None + store.update_task(task, **update_kwargs) + + +def persisted_execution_decision( + store: StateStore, task: Task, *, stage: str, + transition: str | None = None, + failure_class: str | None = None, + evaluated_at: datetime | None = None, +) -> tuple[dict[str, Any], AgentSpec]: + state = store.task_state(task) + decisions = state.get("execution_decisions", {}) + if not isinstance(decisions, dict): + raise ExecutionDecisionError("persisted selector state schema가 유효하지 않다") + is_retry = retry_failover_pending(state) and stage == "worker" + prior_decision = decisions.get(stage) + + retry_ctx = state.get("retry_failover_context") if isinstance(state.get("retry_failover_context"), dict) else {} + + if stage == "review": + decision = read_or_preview_stage_decision( + task, state, stage=stage, evaluated_at=evaluated_at + ) + else: + if transition is None: + if is_retry: + transition = "failover" + failure_class = failure_class or retry_ctx.get("failure_class") + if failure_class not in QUALIFIED_FAILOVER_FAILURES: + raise ExecutionDecisionError( + "retry failover requires persisted qualified runtime failure evidence" + ) + elif prior_decision is not None and stage == "worker" and task.plan and task.plan.is_file(): + current_id = work_unit_id_from_file(task.plan) + prior_id = prior_decision.get("work_unit_id") if isinstance(prior_decision, dict) else None + if current_id and isinstance(prior_id, str) and prior_id and prior_id != current_id: + prior_decision = None + transition = "resume" if prior_decision is not None else "initial" + else: + transition = "resume" if prior_decision is not None else "initial" + + try: + decision = select_execution_decision( + task, stage=stage, prior_decision=prior_decision, + transition=transition, + failure_class=failure_class, + evaluated_at=evaluated_at, + ) + except ExecutionDecisionError as exc: + # A route with no next target resumes the selected runtime so the + # retry budget can make the terminal decision deterministically. + if is_retry and transition == "failover" and "no_failover_candidate" in str(exc): + decision = select_execution_decision( + task, stage=stage, prior_decision=prior_decision, + transition="resume", + evaluated_at=evaluated_at, + ) + else: + raise + + spec = agent_spec_from_decision(decision) + commit_execution_decision(store, task, stage, decision) + return decision, spec + + +def has_persisted_worker_decision(state: dict[str, Any], task: Task | None = None) -> bool: + decisions = state.get("execution_decisions", {}) + if not isinstance(decisions, dict): + return False + prior = decisions.get("worker") + if prior is None: + return False + if task is not None and task.plan and task.plan.is_file(): + current_id = work_unit_id_from_file(task.plan) + if current_id and prior.get("work_unit_id") != current_id: + return False + return True + + +def retry_failover_pending(state: dict[str, Any]) -> bool: + return bool(state.get("retry_failover_pending")) + + +def plan_number(task: Task) -> int: + if task.plan and task.plan.exists(): + match = PLAN_IDENTITY_RE.search( + task.plan.read_text(encoding="utf-8", errors="replace")[:1024] + ) + if match: + return int(match.group("plan")) + return 0 + + +def completing_decision_requires_selfcheck(state: dict[str, Any]) -> bool: + completing = state.get("completing_decision") + if not isinstance(completing, dict): + return False + selected = completing.get("selected") + if not isinstance(selected, dict): + return False + return selected.get("selfcheck_required") is True + + +def _validated_completing_decision( + task: Task, decision: dict[str, Any] +) -> tuple[dict[str, Any], AgentSpec]: + """Strictly validate a completing decision against the task contract. + + Enforces that the decision's stage is "worker", its work_unit_id matches + the task's PLAN identity, and its selected fields pass the canonical + agent/execution-class/selfcheck normalization through `_spec_from_completing_decision`. + + Returns the validated decision and its normalized AgentSpec. + Raises ExecutionDecisionError on any contract violation so that callers + can fail closed rather than advancing to an inconsistent stage. + """ + if not isinstance(decision, dict): + raise ExecutionDecisionError( + "completing decision이 dict가 아니다" + ) + if decision.get("stage") != "worker": + raise ExecutionDecisionError( + f"completing decision stage가 worker가 아니다: {decision.get('stage')!r}" + ) + expected_work_unit_id = work_unit_id_from_file(task.plan) + if decision.get("work_unit_id") != expected_work_unit_id: + raise ExecutionDecisionError( + f"completing decision work_unit_id 불일치: " + f"persisted={decision.get('work_unit_id')!r} " + f"plan={expected_work_unit_id!r}" + ) + spec = _spec_from_completing_decision(decision) + return decision, spec + + +def _completing_decision_is_valid( + task: Task, state: dict[str, Any] +) -> bool: + """Check whether the persisted completing decision satisfies the task contract. + + Validates stage, work_unit_id, and selected agent/execution-class/selfcheck + combination. Used by task_stage to prevent a worker_done state with no + authoritative completing decision from advancing to review. + """ + completing = state.get("completing_decision") + if not isinstance(completing, dict): + return False + try: + _validated_completing_decision(task, completing) + except ExecutionDecisionError: + return False + return True + + +def concrete_user_review_value(value: str) -> bool: + normalized = value.strip().strip("`").strip() + normalized = re.sub(r"^-\s*", "", normalized).strip() + if not normalized or re.search(r"\{[^}]+\}|<[^>]+>", normalized): + return False + return normalized.casefold() not in { + "-", + "n/a", + "na", + "none", + "unknown", + "미정", + "없음", + "해당 없음", + } + + +def user_review_blocker_state(path: Path) -> tuple[bool, str]: + if not path.is_file(): + return False, "파일이 없다" + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + return False, f"파일을 읽을 수 없다: {exc}" + matched_schemas = [ + schema + for schema in USER_REVIEW_SCHEMAS + if re.search( + rf"^##\s*{re.escape(schema['status_heading'])}[ \t]*$", + text, + re.MULTILINE, + ) + ] + if len(matched_schemas) != 1: + return False, "지원하는 USER_REVIEW schema가 정확히 하나가 아니다" + schema = matched_schemas[0] + status = markdown_section(text, schema["status_heading"]).strip().strip("`") + if status != "USER_REVIEW": + return False, "상태가 USER_REVIEW가 아니다" + reason = markdown_section(text, schema["reason_heading"]) + gate_type_matches = re.findall( + rf"(?m)^-\s*{re.escape(schema['type_label'])}:\s*" + r"(milestone-lock|external-execution)\s*$", + reason, + ) + if len(gate_type_matches) != 1: + return False, "지원하는 user-review 유형이 정확히 하나가 아니다" + gate_type = gate_type_matches[0] + target = re.search( + rf"(?m)^-\s*{re.escape(schema['target_label'])}:\s*(.+?)\s*$", + reason, + ) + target_value = target.group(1) if target else "" + if not concrete_user_review_value(target_value): + return False, "구체적인 연결 대상이 없다" + if gate_type == "milestone-lock" and ( + "agent-roadmap/" not in target_value + or "/milestones/" not in target_value + or ".md" not in target_value + ): + return False, "구체적인 Milestone 연결 대상이 없다" + evidence = markdown_section(text, schema["evidence_heading"]) + evidence_line = re.search( + rf"(?m)^-\s*{re.escape(schema['evidence_label'])}:\s*(.+?)\s*$", + evidence, + ) + if evidence_line is None or not concrete_user_review_value( + evidence_line.group(1) + ): + return False, "구체적인 차단 판단 근거가 없다" + decision = markdown_section(text, schema["decision_headings"]) + unresolved = [ + value + for value in re.findall(r"(?m)^-\s*\[\s\]\s+(.+?)\s*$", decision) + if concrete_user_review_value(value) + ] + if not unresolved: + return False, "미해결 사용자 조치 또는 결정 항목이 없다" + resume = markdown_section(text, schema["resume_heading"]) + resume_conditions = [ + line + for line in resume.splitlines() + if concrete_user_review_value(line) + ] + if not resume_conditions: + return False, "구체적인 재개 조건이 없다" + return True, f"unresolved {gate_type} user action or decision" + + +def task_stage(task: Task, state: dict[str, Any]) -> str: + if task.errors: + return "blocked" + if task.user_review: + if task.plan is not None or task.review is not None: + return "blocked" + blocking, _ = user_review_blocker_state(task.user_review) + return "user-review" if blocking else "blocked" + if task.recovery and (task.plan is None or task.review is None): + return "review" + if task.review and task.review.exists(): + text = task.review.read_text(encoding="utf-8", errors="replace") + if verdict_from_text(text): + return "review" + if state.get("worker_done"): + if not _completing_decision_is_valid(task, state): + return "blocked" + if completing_decision_requires_selfcheck(state) and not state.get("selfcheck_done"): + return "selfcheck" + return "review" + return "worker" + + +def markdown_section(text: str, heading: str | tuple[str, ...]) -> str: + headings = (heading,) if isinstance(heading, str) else heading + matches = [] + for h in headings: + for m in re.finditer(rf"^##\s*{re.escape(h)}[ \t]*$", text, re.MULTILINE): + matches.append(m) + if len(matches) != 1: + return "" + match = matches[0] + next_heading = re.search(r"^##\s+", text[match.end():], re.MULTILINE) + end = match.end() + next_heading.start() if next_heading else len(text) + return text[match.end():end].strip() + + +def implementation_review_errors(task: Task) -> list[str]: + if task.review is None or not task.review.is_file(): + return ["CODE_REVIEW 파일 없음"] + text = task.review.read_text(encoding="utf-8", errors="replace") + checklist = markdown_section(text, IMPLEMENTATION_CHECKLIST_HEADINGS) + checkbox_values = IMPLEMENTATION_CHECKBOX_RE.findall(checklist) + if not checkbox_values or any(not value.strip() for value in checkbox_values): + return ["구현 체크리스트 미완료"] + return [] + + +def classify_failure_with_evidence(output: str) -> tuple[str, str | None]: + lines = output.splitlines() + for category, patterns in RUNTIME_FAILURE_PATTERNS.items(): + for line in reversed(lines): + lowered = line.lower() + if any(re.search(pattern, lowered, re.DOTALL) for pattern in patterns): + return category, line + return "generic-error", None + + +def classify_failure(output: str) -> str: + return classify_failure_with_evidence(output)[0] + + +def termination_signal(return_code: int) -> tuple[str, bool] | None: + signal_number: int | None = None + inferred = False + if return_code < 0: + signal_number = -return_code + elif return_code > 128: + signal_number = return_code - 128 + inferred = True + if signal_number is None: + return None + try: + return signal.Signals(signal_number).name, inferred + except ValueError: + return None + + +def failure_report_lines(failure: str, locator: Path) -> list[str]: + record: dict[str, Any] = {} + try: + record = json.loads(locator.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + pass + failure_class = str(record.get("failure_class") or failure) + source = str(record.get("failure_source") or "unverified") + provider_confirmed = bool( + record.get("provider_transport_failure_confirmed", False) + ) + lines = [ + f"failure_class={failure_class}", + f"failure_source={source}", + "provider_transport_failure_confirmed=" + f"{str(provider_confirmed).lower()}", + ] + if record.get("dispatcher_pid") is not None: + lines.append(f"dispatcher_pid={record['dispatcher_pid']}") + if record.get("agent_pid") is not None: + lines.append(f"agent_pid={record['agent_pid']}") + if record.get("dispatcher_source_sha256"): + lines.append( + f"dispatcher_source_sha256={record['dispatcher_source_sha256']}" + ) + source_matches_loaded = record.get("dispatcher_source_matches_loaded") + if source_matches_loaded is not None: + lines.append( + "dispatcher_source_matches_loaded=" + f"{str(bool(source_matches_loaded)).lower()}" + ) + if ( + source_matches_loaded is False + and record.get("dispatcher_source_current_sha256") + ): + lines.append( + "dispatcher_source_current_sha256=" + f"{record['dispatcher_source_current_sha256']}" + ) + if provider_confirmed: + evidence_source = record.get("failure_evidence_source") + evidence = record.get("failure_evidence_excerpt") + if evidence_source: + lines.append(f"provider_evidence_source={evidence_source}") + if evidence: + rendered = str(evidence).replace("\r", r"\r").replace("\n", r"\n") + lines.append(f"provider_evidence={rendered}") + if failure_class == "session-stall": + lines.extend( + [ + f"timeout_phase={record.get('native_session_phase') or 'unknown'}", + f"timeout_seconds={record.get('session_stall_seconds') or 'unknown'}", + "termination_initiator=" + f"{record.get('termination_initiator') or 'dispatcher'}", + ] + ) + elif failure_class == "process-terminated": + lines.extend( + [ + f"termination_signal={record.get('termination_signal') or 'unknown'}", + "termination_initiator=" + f"{record.get('termination_initiator') or 'unknown'}", + ] + ) + lines.append(f"locator={locator}") + return lines + + +def terminal_diagnostic(cli: str, channel: str, line: str) -> str | None: + if channel == "stderr": + return line + try: + value = json.loads(line) + except json.JSONDecodeError: + return line if re.match(r"^\s*(?:error|fatal)\b", line, re.IGNORECASE) else None + if not isinstance(value, dict): + return None + event_type = str(value.get("type", "")).lower() + severity = str(value.get("severity") or value.get("level") or "").lower() + status = str(value.get("status") or "").lower() + subtype = str(value.get("subtype") or "").lower() + if ( + event_type in {"error", "fatal", "request.failed", "turn.failed", "rate_limit_event"} + or severity in {"error", "fatal"} + or subtype.startswith("error") + or bool(value.get("is_error")) + or ( + status in {"failed", "rejected"} + and any(field in value for field in ("code", "error", "error_code", "status_code")) + ) + ): + return json.dumps(value, ensure_ascii=False) + return None + + +def collaboration_tool(line: str) -> str | None: + try: + value = json.loads(line) + except json.JSONDecodeError: + return None + item = value.get("item") or {} + if ( + value.get("type") == "item.started" + and item.get("type") == "collab_tool_call" + and item.get("tool") + ): + return str(item["tool"]) + return None + + +async def terminate_process_group( + process: asyncio.subprocess.Process, + grace_seconds: float = 5, +) -> None: + """Terminate the exact subprocess group and escalate if descendants remain.""" + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + if process.returncode is None: + await process.wait() + return + + if process.returncode is None: + try: + await asyncio.wait_for(process.wait(), timeout=grace_seconds) + except TimeoutError: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + await process.wait() + return + + try: + os.killpg(process.pid, 0) + except ProcessLookupError: + return + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + + +def auxiliary_log_diagnostics(path: Path) -> list[str]: + if not path.exists(): + return [] + diagnostics: list[str] = [] + for line in path.read_text(encoding="utf-8", errors="replace").splitlines()[-200:]: + failure_class, evidence = classify_failure_with_evidence(line) + if ( + failure_class not in RECOVERABLE_RUNTIME_FAILURES + or evidence is None + ): + continue + if failure_class == "provider-quota" and not re.search( + ( + r"RESOURCE[_ ]?EXHAUSTED" + r"|\b(?:HTTP|status(?: code)?)\s*[:=]?\s*429\b" + r"|\btoo many requests\b" + r"|(?:rate.?limit|quota|capacity).{0,40}" + r"(?:exceed|exhaust|reached|reject)" + r"|(?:exceed|exhaust|reached|reject).{0,40}" + r"(?:rate.?limit|quota|capacity)" + r"|(?:rate.?limit|quota).{0,40}retry after" + ), + line, + re.IGNORECASE, + ): + continue + diagnostics.append(line) + return diagnostics + + +def attempt_terminal_diagnostics( + attempt_directory: Path, + record: dict[str, Any], +) -> list[tuple[str, str]]: + spec = agent_spec_from_record(record) + if spec is None: + return [] + try: + stream_lines = (attempt_directory / "stream.log").read_text( + encoding="utf-8", + errors="replace", + ).splitlines() + except OSError: + stream_lines = [] + diagnostics: list[tuple[str, str]] = [] + for stream_line in stream_lines: + match = re.match(r"^\[(stdout|stderr)\]\s?(.*)$", stream_line) + if match is None: + continue + channel, payload = match.groups() + diagnostic = terminal_diagnostic(spec.cli, channel, payload) + if diagnostic: + diagnostics.append((f"{spec.cli}:{channel}", diagnostic)) + for raw_path in record.get("auxiliary_logs", []): + path = Path(str(raw_path)) + diagnostics.extend( + (f"{spec.cli}:auxiliary-log", diagnostic) + for diagnostic in auxiliary_log_diagnostics(path) + ) + return diagnostics + + +def render_json_line(cli: str, line: str) -> tuple[list[str], str | None]: + try: + value = json.loads(line) + except json.JSONDecodeError: + return [line.rstrip()], None + if not isinstance(value, dict): + return [line.rstrip()], None + session_id = value.get("thread_id") or value.get("session_id") + rendered: list[str] = [] + for field in ("text", "result", "message", "output"): + item = value.get(field) + if isinstance(item, str) and item: + rendered.extend(item.splitlines()) + nested = value.get("item") + if isinstance(nested, dict): + for field in ("text", "message", "output"): + item = nested.get(field) + if isinstance(item, str) and item: + rendered.extend(item.splitlines()) + if not rendered and terminal_diagnostic(cli, "stdout", line): + rendered.append(json.dumps(value, ensure_ascii=False)) + return rendered, str(session_id) if session_id else None + + +def native_session_path( + spec: AgentSpec, + workspace: Path, + session_id: str | None, + attempt_dir: Path, +) -> str | None: + template = spec.runtime.get("session_path") + if not isinstance(template, str) or not template or not session_id: + return None + values = { + "agent": spec.cli, + "attempt_dir": str(attempt_dir), + "model": spec.model, + "prompt": "", + "resume_session": "", + "session_id": session_id, + "target_id": str(spec.target_id or ""), + "workspace": str(workspace), + } + rendered = str(template).format_map(values) + candidate = Path(rendered).expanduser() + if not candidate.is_absolute(): + candidate = attempt_dir / candidate + if any(character in str(candidate) for character in "*?["): + matches = sorted( + candidate.parent.glob(candidate.name), + key=lambda path: path.stat().st_mtime_ns, + ) + return str(matches[-1]) if matches else str(candidate.parent) + return str(candidate) + + +def native_session_mtime_ns(path: str | None) -> int | None: + if not path: + return None + candidate = Path(path) + return candidate.stat().st_mtime_ns if candidate.is_file() else None + + +def native_session_state(path: str | None) -> NativeSessionState: + if not path: + return NativeSessionState("starting", reason="native-session-path-missing") + candidate = Path(path) + if not candidate.is_file(): + return NativeSessionState("starting", reason="native-session-file-missing") + return NativeSessionState("active", reason="native-session-file-present") + + +def native_session_phase(path: str | None) -> str: + return native_session_state(path).phase + + +def log_tail_excerpt(path: Path, *, byte_limit: int = 8192, char_limit: int = 2000) -> str: + """Return a bounded recent log excerpt without loading a long reasoning stream.""" + try: + with path.open("rb") as stream: + stream.seek(max(0, path.stat().st_size - byte_limit)) + text = stream.read().decode("utf-8", errors="replace") + except OSError as exc: + return f"" + return text[-char_limit:] + + +def process_start_token(value: Any) -> str | None: + """Read Linux process start ticks so PID reuse is not treated as liveness.""" + try: + pid = int(value) + text = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8") + close = text.rfind(")") + fields = text[close + 2 :].split() + return fields[19] if close >= 0 and len(fields) > 19 else None + except (TypeError, ValueError, OSError): + return None + + +def process_is_alive(value: Any, expected_start_token: Any = None) -> bool: + """Return whether the same attempt/dispatcher process still exists.""" + try: + pid = int(value) + if pid <= 0: + return False + os.kill(pid, 0) + except (TypeError, ValueError, OSError): + return False + current_token = process_start_token(pid) + if ( + expected_start_token is not None + and current_token is not None + and str(expected_start_token) != current_token + ): + return False + return True + + +def marked_agent_process_pids(marker: str) -> list[int]: + """Find live processes carrying the per-attempt environment marker.""" + expected = f"{AGENT_PROCESS_MARKER_ENV}={marker}".encode() + matches: list[int] = [] + for environ in Path("/proc").glob("[0-9]*/environ"): + try: + values = environ.read_bytes().split(b"\0") + pid = int(environ.parent.name) + except (OSError, ValueError): + continue + if expected in values: + matches.append(pid) + return sorted(matches) + + +def locator_workspace_ownership( + locator_path: Path, + locator: dict[str, Any], + *, + expected_workspace: Path | None = None, + expected_workspace_id: str | None = None, + expected_runs_root: Path | None = None, +) -> tuple[bool, str]: + if expected_workspace is None and expected_workspace_id is None: + return True, "" + try: + expected_root = ( + expected_workspace.resolve() if expected_workspace is not None else None + ) + expected_id = expected_workspace_id + if expected_id is None and expected_root is not None: + expected_id = hashlib.sha256(str(expected_root).encode()).hexdigest()[:16] + if expected_runs_root is None: + return False, "현재 workspace의 locator runs root가 없다" + resolved_runs = expected_runs_root.resolve() + resolved_locator = locator_path.resolve() + resolved_locator.relative_to(resolved_runs) + except (OSError, RuntimeError, ValueError): + return ( + False, + "foreign workspace locator path: " + f"locator={locator_path} expected_runs={expected_runs_root}", + ) + + recorded_workspace = locator.get("workspace") + recorded_workspace_id = locator.get("workspace_id") + if recorded_workspace_id not in (None, "") and ( + str(recorded_workspace_id) != str(expected_id) + ): + return ( + False, + "foreign workspace locator id: " + f"recorded={recorded_workspace_id} expected={expected_id}", + ) + if recorded_workspace not in (None, ""): + try: + recorded_root = Path(str(recorded_workspace)).resolve() + except (OSError, RuntimeError): + return False, "locator workspace 경로를 canonicalize할 수 없다" + if expected_root is not None and recorded_root != expected_root: + return ( + False, + "foreign workspace locator root: " + f"recorded={recorded_root} expected={expected_root}", + ) + evidence_fields = ["stream_log"] + runtime = locator.get("runtime") + if isinstance(runtime, dict) and runtime.get("native_session_monitor"): + evidence_fields.append("native_session_path") + for field in evidence_fields: + raw_evidence = locator.get(field) + if raw_evidence in (None, ""): + continue + try: + Path(str(raw_evidence)).resolve().relative_to(resolved_runs) + except (OSError, RuntimeError, ValueError): + return ( + False, + "foreign workspace locator evidence: " + f"field={field} path={raw_evidence}", + ) + # An identity-less legacy locator is accepted only because physical + # containment under the current store's runs root was already proved. + return True, "" + + +def external_active_is_live( + state: dict[str, Any], + *, + expected_workspace: Path | None = None, + expected_workspace_id: str | None = None, + expected_runs_root: Path | None = None, +) -> tuple[bool, str]: + raw_locator = state.get("active_locator") + if not raw_locator: + return False, "active locator 없음" + target = Path(str(raw_locator)) + locator_path = target if target.name == "locator.json" else target / "locator.json" + path_owned, ownership_detail = locator_workspace_ownership( + locator_path, + {}, + expected_workspace=expected_workspace, + expected_workspace_id=expected_workspace_id, + expected_runs_root=expected_runs_root, + ) + if not path_owned: + return False, ownership_detail + locator: dict[str, Any] = {} + if locator_path.is_file(): + try: + locator = json.loads(locator_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False, f"locator 판독 실패: {locator_path}" + if not isinstance(locator, dict): + return False, f"locator object 형식이 아니다: {locator_path}" + + owned, ownership_detail = locator_workspace_ownership( + locator_path, + locator, + expected_workspace=expected_workspace, + expected_workspace_id=expected_workspace_id, + expected_runs_root=expected_runs_root, + ) + if not owned: + return False, ownership_detail + + if locator: + status = str(locator.get("status") or "") + if status and status != "running": + return False, f"locator status={status}" + + # The stream may legitimately remain quiet during long reasoning. A live + # process is stronger evidence than a locator or dispatcher heartbeat, and + # prevents a second dispatcher from duplicating an active attempt. + agent_pid_recorded = locator.get("agent_pid") not in (None, "") + for field, token_field in ( + ("agent_pid", "agent_process_start_token"), + ("dispatcher_pid", "dispatcher_process_start_token"), + ): + if process_is_alive(locator.get(field), locator.get(token_field)): + return True, f"{field}={locator[field]} alive; output stream is monitored" + process_marker = str(locator.get("agent_process_marker") or "") + if process_marker: + marker_pids = marked_agent_process_pids(process_marker) + if marker_pids: + return ( + True, + "agent process marker alive: " + + ",".join(str(pid) for pid in marker_pids), + ) + return ( + False, + "agent process marker is absent from the process table", + ) + if agent_pid_recorded: + return ( + False, + "recorded agent process identity is no longer alive", + ) + + native_raw = locator.get("native_session_path") + native = Path(str(native_raw)) if native_raw else None + if native and native.is_dir(): + sessions = list(native.glob("*.jsonl")) + native = max(sessions, key=lambda path: path.stat().st_mtime_ns) if sessions else None + if native is None or not native.is_file(): + roots = [target] if target.is_dir() else [target.parent] + sessions = [ + path + for root in roots + for path in root.glob("**/*.jsonl") + ] + native = max(sessions, key=lambda path: path.stat().st_mtime_ns) if sessions else None + now = datetime.now(timezone.utc).timestamp() + runtime = locator.get("runtime") + monitor_native_session = bool( + isinstance(runtime, dict) and runtime.get("native_session_monitor") + ) + stream_progress_at: float | None = None + stream_raw = locator.get("stream_log") + stream = Path(str(stream_raw)) if stream_raw else None + if stream and stream.is_file(): + stream_progress_at = stream.stat().st_mtime + if native and native.is_file(): + native_progress_at = native.stat().st_mtime + progress_at = max(native_progress_at, stream_progress_at or 0.0) + inactive = max(0.0, now - progress_at) + if monitor_native_session: + phase = native_session_phase(str(native)) + # Only an exact incomplete toolCall -> toolResult batch is a tool + # execution interval. Unknown/starting/model-reasoning states + # must never be treated as a stalled tool merely because their + # native event file is quiet. + if phase == "tool-running": + return ( + True, + "phase=tool-running with no agent PID evidence; " + "time-based duplicate recovery is disabled", + ) + return ( + True, + f"phase={phase} native+stream inactive={inactive:.1f}s " + "with no agent PID evidence; time-based duplicate recovery is disabled", + ) + return ( + True, + "native+stream inactive=" + f"{inactive:.1f}s with no agent PID evidence; " + "time-based duplicate recovery is disabled", + ) + + if stream_progress_at is not None: + inactive = max(0.0, now - stream_progress_at) + return ( + True, + f"stream inactive={inactive:.1f}s with no agent PID evidence; " + "time-based duplicate recovery is disabled", + ) + + return False, f"active 증거 없음: {raw_locator}" + + +def native_resume_locator( + state: dict[str, Any], + *, + expected_workspace: Path | None = None, + expected_workspace_id: str | None = None, + expected_runs_root: Path | None = None, +) -> Path | None: + raw_locator = state.get("active_locator") + if not raw_locator: + return None + target = Path(str(raw_locator)) + locator = target if target.name == "locator.json" else target / "locator.json" + path_owned, _ = locator_workspace_ownership( + locator, + {}, + expected_workspace=expected_workspace, + expected_workspace_id=expected_workspace_id, + expected_runs_root=expected_runs_root, + ) + if not path_owned: + return None + try: + record = json.loads(locator.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(record, dict): + return None + owned, _ = locator_workspace_ownership( + locator, + record, + expected_workspace=expected_workspace, + expected_workspace_id=expected_workspace_id, + expected_runs_root=expected_runs_root, + ) + if not owned: + return None + if ( + not isinstance(record.get("runtime"), dict) + or not record["runtime"].get("native_session_monitor") + or record.get("failure_class") not in {"context-limit", "session-stall"} + or record.get("status") != "failed" + ): + return None + native_raw = record.get("native_session_path") + native = Path(str(native_raw)) if native_raw else None + if native is None or not native.exists(): + return None + if expected_runs_root is not None: + try: + native.resolve().relative_to(expected_runs_root.resolve()) + except (OSError, RuntimeError, ValueError): + return None + return locator + + +def selfcheck_context_resume_locator( + state: dict[str, Any], + task: Task, + *, + expected_workspace: Path, + expected_workspace_id: str, + expected_runs_root: Path, +) -> tuple[Path | None, str]: + raw_locator = state.get("selfcheck_context_locator") + if not isinstance(raw_locator, str) or not raw_locator: + return None, "persisted selfcheck context locator가 없다" + target = Path(raw_locator) + locator = target if target.name == "locator.json" else target / "locator.json" + path_owned, detail = locator_workspace_ownership( + locator, + {}, + expected_workspace=expected_workspace, + expected_workspace_id=expected_workspace_id, + expected_runs_root=expected_runs_root, + ) + if not path_owned: + return None, detail + try: + record = json.loads(locator.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None, "persisted selfcheck context locator를 읽을 수 없다" + if not isinstance(record, dict): + return None, "persisted selfcheck context locator 형식이 잘못됐다" + owned, detail = locator_workspace_ownership( + locator, + record, + expected_workspace=expected_workspace, + expected_workspace_id=expected_workspace_id, + expected_runs_root=expected_runs_root, + ) + if not owned: + return None, detail + if ( + record.get("task") != task.name + or record.get("role") != "selfcheck" + or not isinstance(record.get("runtime"), dict) + or not record["runtime"].get("native_session_monitor") + or record.get("status") != "succeeded" + ): + return None, "persisted selfcheck context locator identity가 일치하지 않는다" + native_raw = record.get("native_session_path") + native = Path(str(native_raw)) if native_raw else None + if native is not None and native.is_dir(): + sessions = list(native.glob("*.jsonl")) + native = max(sessions, key=lambda path: path.stat().st_mtime_ns) if sessions else None + if native is None or not native.is_file(): + return None, "persisted selfcheck native session이 없다" + try: + native.resolve().relative_to(expected_runs_root.resolve()) + except (OSError, RuntimeError, ValueError): + return None, "persisted selfcheck native session이 workspace runs 밖에 있다" + return locator, "" + + +def build_command( + spec: AgentSpec, + prompt: str, + workspace: Path, + session_id: str, + attempt_dir: Path, + native_resume_session: Path | None = None, +) -> list[str]: + template_name = ( + "resume_command" + if native_resume_session is not None and spec.runtime.get("resume_command") + else "command" + ) + template = spec.runtime.get(template_name) + if not isinstance(template, list) or not template: + raise RuntimeError( + f"runtime catalog target {spec.target_id!r} has no {template_name}" + ) + values = { + "agent": spec.cli, + "attempt_dir": str(attempt_dir), + "model": spec.model, + "prompt": prompt, + "resume_session": str(native_resume_session or ""), + "session_id": session_id, + "target_id": str(spec.target_id or ""), + "workspace": str(workspace), + } + try: + return [str(part).format_map(values) for part in template] + except (KeyError, ValueError) as exc: + raise RuntimeError( + f"runtime command template expansion failed for {spec.target_id!r}: {exc}" + ) from exc + + +def preflight_execution_catalog( + catalog_path: Path, + *, + workspace: Path | None = None, + run_commands: bool = True, +) -> None: + selector = _selector_module() + catalog = selector.load_runtime_catalog(catalog_path) + checked_workspace = (workspace or Path.cwd()).resolve() + checked_commands: set[str] = set() + for target_id, target in catalog.targets.items(): + values = { + "agent": target.agent, + "attempt_dir": str(catalog_path.parent), + "model": target.model, + "prompt": "", + "resume_session": "", + "session_id": "preflight-session", + "target_id": target_id, + "workspace": str(checked_workspace), + } + command = [str(part).format_map(values) for part in target.runtime["command"]] + executable = command[0] + if executable not in checked_commands and shutil.which(executable) is None: + raise ExecutionDecisionError( + f"execution catalog target {target_id!r} command not found: {executable}" + ) + checked_commands.add(executable) + probe_template = target.runtime.get("preflight_command") + if not probe_template or not run_commands: + continue + probe = [str(part).format_map(values) for part in probe_template] + probe_environment = { + str(key): str(item).format_map(values) + for key, item in target.runtime.get("environment", {}).items() + } + completed = subprocess.run( + probe, + cwd=checked_workspace, + env={**os.environ, **probe_environment}, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + if completed.returncode != 0: + diagnostic = (completed.stderr or completed.stdout).strip() + raise ExecutionDecisionError( + f"execution catalog target {target_id!r} preflight failed: " + f"{diagnostic or completed.returncode}" + ) + + +async def invoke( + workspace: Path, + store: StateStore, + task: Task, + role: str, + spec: AgentSpec, + prompt: str, + resume_locator: Path | None = None, +) -> tuple[int, str | None, Path]: + attempt, identity = next_execution_identity(store, task, role) + attempt_dir = store.runs / f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}__{identity}" + attempt_dir.mkdir(parents=True, exist_ok=False) + locator_path = attempt_dir / "locator.json" + stream_path = attempt_dir / "stream.log" + normalized_output_path = attempt_dir / "normalized-output.log" + heartbeat_path = attempt_dir / "heartbeat.log" + stream_path.touch() + normalized_output_path.touch() + heartbeat_path.touch() + session_id = str(uuid.uuid4()) + process_marker = f"w{store.workspace_id}__{identity}__{uuid.uuid4()}" + native_resume_session: Path | None = None + if spec.native_resume and resume_locator and resume_locator.is_file(): + resume_locator_path = ( + resume_locator + if resume_locator.name == "locator.json" + else resume_locator / "locator.json" + ) + path_owned, _ = locator_workspace_ownership( + resume_locator_path, + {}, + expected_workspace=store.workspace, + expected_workspace_id=store.workspace_id, + expected_runs_root=store.runs, + ) + if path_owned: + try: + prior = json.loads(resume_locator_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + prior = {} + else: + prior = {} + owned, _ = locator_workspace_ownership( + resume_locator_path, + prior if isinstance(prior, dict) else {}, + expected_workspace=store.workspace, + expected_workspace_id=store.workspace_id, + expected_runs_root=store.runs, + ) + if owned and isinstance(prior, dict): + prior_native = prior.get("native_session_path") + candidate = Path(str(prior_native)) if prior_native else None + if candidate and candidate.is_dir(): + sessions = list(candidate.glob("*.jsonl")) + candidate = ( + max(sessions, key=lambda path: path.stat().st_mtime_ns) + if sessions + else None + ) + if candidate and candidate.is_file(): + try: + candidate.resolve().relative_to(store.runs.resolve()) + except (OSError, RuntimeError, ValueError): + candidate = None + if candidate and candidate.is_file(): + native_resume_session = candidate + resume_locator = resume_locator_path + session_id = str(prior.get("session_id") or candidate.stem) + started_at = now_iso() + work_log_path = milestone_work_log_path(task) + record: dict[str, Any] = { + "execution_id": identity, + "task": task.name, + "task_directory": str(task.directory.resolve()), + "target_files": task_target_files(task), + "target_files_known": task.write_set_known, + "plan_number": plan_number(task), + "role": role, + "attempt": attempt, + "workspace": str(store.workspace), + "workspace_id": store.workspace_id, + **dispatcher_source_provenance(), + "cli": spec.cli, + "model": spec.model, + "target_id": spec.target_id, + "execution_class": spec.execution_class, + "selfcheck_required": spec.selfcheck_required, + "runtime": spec.runtime, + "agent_process_marker": process_marker, + "plan_path": str(task.plan) if task.plan else None, + "review_path": str(task.review) if task.review else None, + "session_id": session_id, + "native_session_path": ( + str(native_resume_session) + if native_resume_session is not None + else native_session_path(spec, workspace, session_id, attempt_dir) + ), + "output_log": str(stream_path), + "stream_log": str(stream_path), + "normalized_output_log": str(normalized_output_path), + "heartbeat_log": str(heartbeat_path), + "auxiliary_logs": [ + str(item).format_map( + { + "agent": spec.cli, + "attempt_dir": str(attempt_dir), + "model": spec.model, + "prompt": "", + "resume_session": str(native_resume_session or ""), + "session_id": session_id, + "target_id": str(spec.target_id or ""), + "workspace": str(workspace), + } + ) + for item in spec.runtime.get("auxiliary_logs", []) + ], + "work_log": str(work_log_path.resolve()), + "started_at": started_at, + "status": "running", + "resumed_from_locator": str(resume_locator) if native_resume_session else None, + } + stage_decision = None + if isinstance(store, StateStore): + decisions = store.task_state(task).get("execution_decisions", {}) + if isinstance(decisions, dict): + stage_decision = decisions.get(role) + if isinstance(stage_decision, dict): + record.update(selector_runtime_evidence(stage_decision)) + try: + record["stage_budget"] = StageFailureBudget.from_decision(store, task, stage_decision).count() + except Exception: + record["stage_budget"] = 0 + # Resolve the retry handoff identity assigned when a pending target + # failover was created before the first durable locator write, so + # the first record on disk already carries the stable handoff ID a + # crash/restart can match against (the locator path changes on every + # attempt). + retry_handoff_id: str | None = None + if isinstance(store, StateStore): + retry_ctx = store.task_state(task).get("retry_failover_context") + if isinstance(retry_ctx, dict): + retry_handoff_id = retry_ctx.get("handoff_id") + if retry_handoff_id: + record["retry_handoff_id"] = retry_handoff_id + write_json(locator_path, record) + if isinstance(store, StateStore): + if retry_handoff_id: + # One-save transition: update active_locator, clear pending flag, + # and clear context together. Restore pre-state on save failure. + # A mismatch (False) or a save fault (raises) must stop before + # the provider process seam so we never launch a duplicate + # invocation against a retry intent we failed to commit. + if not store.commit_retry_handoff_locator(task, retry_handoff_id, str(locator_path)): + raise ExecutionDecisionError("retry handoff commit mismatch") + else: + store.update_task(task, active_locator=str(locator_path)) + prefix = f"[{task.directory.name}][{role}][a{attempt:02d}]" + + def persist_locator_record() -> None: + """Do not abort a live model solely because a locator refresh failed.""" + try: + write_json(locator_path, record) + except OSError as exc: + record["locator_write_error"] = str(exc) + attempt_event( + prefix, + f"locator 기록 경고: locator={locator_path} error={exc}", + ) + + for line in task_observation_lines(task): + attempt_event(prefix, line) + attempt_event(prefix, f"locator={locator_path}") + try: + append_milestone_event( + task, + event="START", + execution_id=identity, + role=role, + attempt=attempt, + model=spec.display, + result="running", + locator=locator_path, + ) + except OSError as exc: + line = f"milestone work log setup failed: {exc}" + heartbeat_path.write_text(line + "\n", encoding="utf-8") + record.update( + status="failed", + finished_at=now_iso(), + exit_code=1, + failure_class="work-log-setup", + failure_source="work-log", + provider_transport_failure_confirmed=False, + work_log_error=str(exc), + ) + persist_locator_record() + attempt_event(prefix, line) + return 1, "work-log-setup", locator_path + command = build_command( + spec, + prompt, + workspace, + session_id, + attempt_dir, + native_resume_session=native_resume_session, + ) + diagnostics: list[str] = [] + diagnostic_origins: list[str] = [] + control_violation: str | None = None + try: + runtime_values = { + "agent": spec.cli, + "attempt_dir": str(attempt_dir), + "model": spec.model, + "prompt": prompt, + "resume_session": str(native_resume_session or ""), + "session_id": session_id, + "target_id": str(spec.target_id or ""), + "workspace": str(workspace), + } + runtime_environment = { + str(key): str(value).format_map(runtime_values) + for key, value in spec.runtime.get("environment", {}).items() + } + process = await asyncio.create_subprocess_exec( + *command, + cwd=workspace, + env={ + **os.environ, + AGENT_PROCESS_MARKER_ENV: process_marker, + **runtime_environment, + }, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + limit=10 * 1024 * 1024, + start_new_session=True, + ) + # Keep the child PID in the locator before monitoring output. If this + # dispatcher is interrupted, a later dispatcher can distinguish a + # genuinely live, silent model from a stale locator and must not launch + # a duplicate continuation. + record["agent_pid"] = process.pid + record["agent_process_start_token"] = process_start_token(process.pid) + persist_locator_record() + except FileNotFoundError: + line = f"command not found: {command[0]}" + heartbeat_path.write_text(line + "\n", encoding="utf-8") + failure_class = "generic-error" + try: + append_milestone_event( + task, + event="FINISH", + execution_id=identity, + role=role, + attempt=attempt, + model=spec.display, + result=f"failed:{failure_class}:127", + locator=locator_path, + ) + except OSError as exc: + record["work_log_runtime_error"] = str(exc) + failure_class = "work-log-runtime-write" + record.update( + status="failed", + finished_at=now_iso(), + exit_code=127, + failure_class=failure_class, + failure_source=( + "work-log" if failure_class == "work-log-runtime-write" else "cli-launch" + ), + provider_transport_failure_confirmed=False, + ) + persist_locator_record() + attempt_event(prefix, line) + return 127, failure_class, locator_path + + readers: list[asyncio.Task[None]] = [] + try: + assert process.stdout is not None and process.stderr is not None + queue: asyncio.Queue[tuple[str, bytes | None]] = asyncio.Queue() + + async def pump(channel: str, stream: asyncio.StreamReader) -> None: + try: + while True: + raw = await stream.readline() + if not raw: + break + await queue.put((channel, raw)) + finally: + await queue.put((channel, None)) + + readers = [ + asyncio.create_task(pump("stdout", process.stdout)), + asyncio.create_task(pump("stderr", process.stderr)), + ] + finished_streams = 0 + loop = asyncio.get_running_loop() + last_native_mtime: int | None = None + last_stream_mtime: int | None = None + last_native_progress_at = loop.time() + last_stream_progress_at = loop.time() + with ( + stream_path.open("w", encoding="utf-8") as stream_log, + normalized_output_path.open("w", encoding="utf-8") as normalized_output_log, + heartbeat_path.open("a", encoding="utf-8") as heartbeat_log, + ): + while finished_streams < len(readers): + try: + channel, raw = await asyncio.wait_for( + queue.get(), timeout=STREAM_HEARTBEAT_SECONDS + ) + except asyncio.TimeoutError: + try: + stream_mtime = stream_path.stat().st_mtime_ns + except OSError: + stream_mtime = None + if stream_mtime is not None: + record["stream_log_mtime_ns"] = stream_mtime + if stream_mtime != last_stream_mtime: + last_stream_mtime = stream_mtime + last_stream_progress_at = loop.time() + record.pop("native_silence_inspection", None) + native_path = ( + str(native_resume_session) + if native_resume_session is not None + else native_session_path( + spec, + workspace, + record.get("session_id"), + attempt_dir, + ) + ) + if native_path: + record["native_session_path"] = native_path + native_mtime = native_session_mtime_ns( + record.get("native_session_path") + ) + if native_mtime is not None: + record["native_session_mtime_ns"] = native_mtime + if native_mtime != last_native_mtime: + last_native_mtime = native_mtime + last_native_progress_at = loop.time() + # Native events and the separately flushed stream log + # are peer progress signals. A trailing toolResult only + # selects the timeout budget; it never overrides later + # reasoning/text output. + record["native_activity_state"] = "working" + native_state = native_session_state( + record.get("native_session_path") + ) + native_phase = native_state.phase + is_native_tool_execution = native_phase == "tool-running" + # Outside a toolCall->toolResult interval, model stdout/stderr + # is the liveness signal. A completed tool result changes phase + # but must not reset the model-response silence clock. + native_inactive_seconds = loop.time() - ( + max(last_native_progress_at, last_stream_progress_at) + if is_native_tool_execution + else last_stream_progress_at + ) + if spec.native_resume: + record["native_session_phase"] = native_phase + record["native_session_phase_reason"] = ( + native_state.reason + ) + record["native_expected_tool_call_ids"] = list( + native_state.expected_tool_call_ids + ) + record["native_completed_tool_call_ids"] = list( + native_state.completed_tool_call_ids + ) + record["native_pending_tool_call_ids"] = list( + native_state.pending_tool_call_ids + ) + record["native_stall_timeout_seconds"] = None + record.setdefault("native_activity_state", "starting") + if ( + spec.native_resume + and not is_native_tool_execution + and native_inactive_seconds >= MODEL_RESPONSE_STALL_SECONDS + and "native_silence_inspection" not in record + ): + inspection = { + "at": now_iso(), + "silence_seconds": round(native_inactive_seconds, 3), + "stream_tail": log_tail_excerpt(stream_path), + } + record["native_silence_inspection"] = inspection + diagnostic = ( + f"native-session {native_phase} stream produced no update for " + f"{native_inactive_seconds:.1f}s; recorded stream tail for inspection " + "without terminating the model process" + ) + heartbeat_log.write(f"[silence-inspection] {diagnostic}\n") + heartbeat_log.flush() + persist_locator_record() + attempt_event(prefix, f"모델응답점검: {diagnostic}") + non_native_inactive_seconds = loop.time() - max( + last_native_progress_at, last_stream_progress_at + ) + if ( + not spec.native_resume + and non_native_inactive_seconds + >= MODEL_RESPONSE_STALL_SECONDS + and "stream_silence_inspection" not in record + ): + inspection = { + "at": now_iso(), + "silence_seconds": round(non_native_inactive_seconds, 3), + "stream_tail": log_tail_excerpt(stream_path), + } + record["stream_silence_inspection"] = inspection + diagnostic = ( + f"{spec.cli} emitted no stream output or native-session event for " + f"{non_native_inactive_seconds:.1f}s; recorded stream tail for inspection " + "without terminating the model process" + ) + heartbeat_log.write(f"[silence-inspection] {diagnostic}\n") + heartbeat_log.flush() + persist_locator_record() + attempt_event(prefix, f"모델응답점검: {diagnostic}") + heartbeat = ( + f"작업중... locator={locator_path} " + f"native_session={record.get('native_session_path') or 'none'} " + f"native_mtime_ns={record.get('native_session_mtime_ns', 'none')}" + ) + if spec.native_resume: + heartbeat += ( + f" native_activity={record.get('native_activity_state')}" + f" native_phase={native_phase}" + ) + heartbeat_log.write(f"[heartbeat] {heartbeat}\n") + heartbeat_log.flush() + persist_locator_record() + # Heartbeat is recovery state, not a user-visible lifecycle + # event. Keep it out of the caller-facing event stream. + continue + if raw is None: + finished_streams += 1 + continue + record.pop("native_silence_inspection", None) + record.pop("stream_silence_inspection", None) + if spec.native_resume and channel == "stdout": + record["native_activity_state"] = "streaming" + line = raw.decode("utf-8", errors="replace").rstrip("\n") + stream_log.write(f"[{channel}] {line}\n") + stream_log.flush() + diagnostic = terminal_diagnostic(spec.cli, channel, line) + if diagnostic: + diagnostics.append(diagnostic) + diagnostic_origins.append(f"{spec.cli}:{channel}") + if role == "review" and channel == "stdout": + invoked_tool = collaboration_tool(line) + if invoked_tool and control_violation is None: + control_violation = invoked_tool + diagnostics.append( + f"official review invoked forbidden collaboration tool: " + f"{invoked_tool}" + ) + diagnostic_origins.append("dispatcher:review-control") + attempt_event( + prefix, + f"리뷰 제어 계약 위반: collaboration-tool=" + f"{invoked_tool}", + ) + await terminate_process_group(process) + rendered, discovered = ( + render_json_line(spec.cli, line) if channel == "stdout" else ([line], None) + ) + if discovered and record.get("session_id") != discovered: + record["session_id"] = discovered + if native_resume_session is None: + record["native_session_path"] = native_session_path( + spec, workspace, discovered, attempt_dir + ) + persist_locator_record() + for display_line in rendered: + if display_line: + normalized_output_log.write(display_line + "\n") + normalized_output_log.flush() + # Child output is retained for recovery and review but is + # not itself a dispatcher lifecycle event. + await asyncio.gather(*readers) + return_code = await process.wait() + except asyncio.CancelledError: + for reader in readers: + reader.cancel() + if readers: + await asyncio.gather(*readers, return_exceptions=True) + await terminate_process_group(process) + runtime_error: OSError | None = None + try: + append_milestone_event( + task, + event="FINISH", + execution_id=identity, + role=role, + attempt=attempt, + model=spec.display, + result="failed:cancelled", + locator=locator_path, + ) + except OSError as exc: + runtime_error = exc + record.update( + status="failed", + finished_at=now_iso(), + exit_code="cancelled", + failure_class="cancelled", + failure_source="caller-cancel", + provider_transport_failure_confirmed=False, + ) + if runtime_error is not None: + record["work_log_runtime_error"] = str(runtime_error) + persist_locator_record() + raise + + for raw_path in record.get("auxiliary_logs", []): + aux_diagnostics = auxiliary_log_diagnostics(Path(str(raw_path))) + diagnostics.extend(aux_diagnostics) + diagnostic_origins.extend( + f"{spec.cli}:auxiliary-log" for _ in aux_diagnostics + ) + native_path = ( + str(native_resume_session) + if native_resume_session is not None + else native_session_path( + spec, workspace, record.get("session_id"), attempt_dir + ) + ) + if native_path: + record["native_session_path"] = native_path + native_mtime = native_session_mtime_ns(record.get("native_session_path")) + if native_mtime is not None: + record["native_session_mtime_ns"] = native_mtime + failure_source: str | None = None + failure_evidence: str | None = None + failure_evidence_source: str | None = None + provider_transport_failure_confirmed = False + termination = termination_signal(return_code) + if termination is not None: + record["termination_signal"] = termination[0] + record["termination_signal_inferred"] = termination[1] + if control_violation: + failure_class = "review-control-violation" + failure_source = "dispatcher-control" + failure_evidence_source = "dispatcher:review-control" + for index in range(len(diagnostics) - 1, -1, -1): + if diagnostic_origins[index] == failure_evidence_source: + failure_evidence = diagnostics[index] + break + elif return_code != 0 and termination is not None: + failure_class = "process-terminated" + failure_source = "process-termination" + record["termination_initiator"] = "unknown" + else: + classified_failure, classified_evidence = classify_failure_with_evidence( + "\n".join(diagnostics[-50:]) + ) + if return_code != 0 or classified_evidence is not None: + failure_class = classified_failure + failure_evidence = classified_evidence + else: + failure_class = None + if failure_class is not None and failure_evidence is not None: + for index in range(len(diagnostics) - 1, -1, -1): + if diagnostics[index] == failure_evidence: + failure_evidence_source = diagnostic_origins[index] + break + if failure_class in PROVIDER_TRANSPORT_FAILURES: + failure_source = "provider-terminal-diagnostic" + provider_transport_failure_confirmed = failure_evidence is not None + elif failure_evidence is not None: + failure_source = "cli-terminal-diagnostic" + elif return_code != 0: + failure_source = "cli-exit" + try: + append_milestone_event( + task, + event="FINISH", + execution_id=identity, + role=role, + attempt=attempt, + model=spec.display, + result=( + f"succeeded:0" + if return_code == 0 and failure_class is None + else f"failed:{failure_class or 'generic-error'}:{return_code}" + ), + locator=locator_path, + ) + except OSError as exc: + if failure_class is not None: + record["prior_failure_class"] = failure_class + record["work_log_runtime_error"] = str(exc) + failure_class = "work-log-runtime-write" + failure_source = "work-log" + failure_evidence = None + failure_evidence_source = None + provider_transport_failure_confirmed = False + if failure_evidence is not None: + record["failure_evidence_excerpt"] = failure_evidence[:FAILURE_EVIDENCE_LIMIT] + record["failure_evidence_truncated"] = ( + len(failure_evidence) > FAILURE_EVIDENCE_LIMIT + ) + if failure_evidence_source is not None: + record["failure_evidence_source"] = failure_evidence_source + record.update( + status="succeeded" if return_code == 0 and failure_class is None else "failed", + finished_at=now_iso(), + exit_code=return_code, + failure_class=failure_class, + failure_source=failure_source, + provider_transport_failure_confirmed=provider_transport_failure_confirmed, + ) + persist_locator_record() + return return_code, failure_class, locator_path + + +def dispatcher_child_prompt(body: str) -> str: + return f"{DISPATCHER_CHILD_BOUNDARY_PROMPT} {body}" + + +def selfcheck_prompt(task: Task, *, unchecked_items: bool = False) -> str: + if task.plan is None: + raise RuntimeError("selfcheck PLAN이 없다") + if task.review is None: + raise RuntimeError("selfcheck CODE_REVIEW 파일이 없다") + if unchecked_items: + body = ( + f"Read {task.plan.resolve()}; complete every unchecked implementation " + f"item and update {task.review.resolve()}. {REPOSITORY_LANGUAGE_PROMPT}" + ) + else: + body = ( + f"Read {task.plan.resolve()}; review all work once, fix omissions, " + f"and update {task.review.resolve()}. {REPOSITORY_LANGUAGE_PROMPT}" + ) + return f"{SELF_CHECK_PROMPT_PREFIX} {body}" + + +def base_prompt( + task: Task, + role: str, + spec: AgentSpec, + *, + unchecked_items: bool = False, +) -> str: + if role == "review": + target = task.review or task.directory + if task.review: + return dispatcher_child_prompt( + f"Read {target.resolve()} and start the review. " + f"{REPOSITORY_LANGUAGE_PROMPT}" + ) + return dispatcher_child_prompt( + f"Continue the review for {target.resolve()}. " + f"{REPOSITORY_LANGUAGE_PROMPT}" + ) + if task.plan is None: + raise RuntimeError("worker PLAN이 없다") + target = task.plan.resolve() + if role == "selfcheck": + return selfcheck_prompt(task, unchecked_items=unchecked_items) + if spec.native_resume: + return dispatcher_child_prompt( + f"Read {target} and complete the task. {REPOSITORY_LANGUAGE_PROMPT}" + ) + return dispatcher_child_prompt( + f"Read {target} and complete the task. {REPOSITORY_LANGUAGE_PROMPT}" + ) + + + +def build_context_package( + workspace: Path, task: Task, locator: Path, *, previous_spec: AgentSpec, next_spec: AgentSpec +) -> dict[str, Any]: + """Build the fail-closed continuation context for a target transition.""" + + if not locator.is_file(): + raise ExecutionDecisionError("logical context locator가 없다") + try: + record = json.loads(locator.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ExecutionDecisionError("logical context locator를 읽을 수 없다") from exc + if not isinstance(record, dict): + raise ExecutionDecisionError("logical context locator 형식이 잘못됐다") + workspace_value = record.get("workspace") + if not isinstance(workspace_value, str) or not workspace_value: + raise ExecutionDecisionError("logical context workspace가 없다") + recorded_workspace = Path(workspace_value) + if not recorded_workspace.is_absolute() or recorded_workspace.resolve() != workspace.resolve(): + raise ExecutionDecisionError("logical context workspace가 일치하지 않는다") + if record.get("task") != task.name: + raise ExecutionDecisionError("logical context task가 일치하지 않는다") + if task.plan is None or not task.plan.is_file(): + raise ExecutionDecisionError("logical context PLAN이 없다") + plan_value = record.get("plan_path") + if not isinstance(plan_value, str) or not plan_value: + raise ExecutionDecisionError("logical context PLAN 경로가 없다") + plan_path = Path(plan_value) + if not plan_path.is_absolute() or plan_path.resolve() != task.plan.resolve(): + raise ExecutionDecisionError("logical context PLAN 경로가 일치하지 않는다") + required = { + "normalized_output": ("normalized_output_log", "normalized-output.log"), + "raw_log": ("stream_log", "stream.log"), + } + paths: dict[str, str] = {} + attempt_dir = locator.resolve().parent + for field, (record_field, filename) in required.items(): + value = record.get(record_field) + if not isinstance(value, str) or not value: + raise ExecutionDecisionError(f"logical context {field} artifact가 없다") + path = Path(value) + expected = attempt_dir / filename + if not path.is_absolute() or path.resolve() != expected or not expected.is_file(): + raise ExecutionDecisionError(f"logical context {field} artifact가 locator attempt와 일치하지 않는다") + paths[field] = str(expected) + same_native = previous_spec.native_resume and next_spec.native_resume + package = { + "plan": str(task.plan.resolve()), "locator": str(locator.resolve()), + "workspace": str(workspace.resolve()), **paths, + "resume_mode": "native" if same_native else "logical", + } + if same_native: + native = Path(str(record.get("native_session_path", ""))) + if not native.is_file(): + raise ExecutionDecisionError("same-native-session logical context native session이 없다") + package["native_session_path"] = str(native.resolve()) + return package + + +def canonical_selector_failover_route(decision: dict[str, Any] | None) -> bool: + if not isinstance(decision, dict): + return False + candidates = decision.get("candidates") + return isinstance(candidates, list) and len(candidates) > 1 + + +def logical_context_prompt(context: dict[str, Any]) -> str: + plan = context["plan"] + locator = context["locator"] + workspace = context["workspace"] + raw_log = context["raw_log"] + normalized_output = context["normalized_output"] + return dispatcher_child_prompt( + f"{REPOSITORY_LANGUAGE_PROMPT} " + f"Read plan={plan}, locator={locator}, workspace={workspace}, " + f"raw_log={raw_log}, normalized_output={normalized_output} and complete the task." + ) + + +def continuation_prompt_from_package( + context_package: dict[str, Any], + *, + target: dict[str, Any] | None = None, + native_resume: bool = False, +) -> str: + if native_resume or context_package.get("resume_mode") == "native": + return dispatcher_child_prompt( + f"{REPOSITORY_LANGUAGE_PROMPT} Continue this session and complete " + "the current task." + ) + plan = context_package["plan"] + locator = context_package["locator"] + workspace = context_package["workspace"] + raw_log = context_package["raw_log"] + normalized_output = context_package["normalized_output"] + return dispatcher_child_prompt( + f"{REPOSITORY_LANGUAGE_PROMPT} " + f"Read plan={plan}, locator={locator}, workspace={workspace}, " + f"raw_log={raw_log}, normalized_output={normalized_output} and complete the task." + ) + + +def continuation_prompt( + task: Task, + role: str, + locator: Path | None = None, + *, + native_resume: bool = False, + resume_same_native_session: bool = False, + context: dict[str, Any] | None = None, + unchecked_items: bool = False, +) -> str: + if native_resume and role == "selfcheck": + if resume_same_native_session: + if unchecked_items: + return selfcheck_prompt(task, unchecked_items=True) + return ( + f"{SELF_CHECK_PROMPT_PREFIX} Continue." + ) + return selfcheck_prompt(task, unchecked_items=unchecked_items) + if context is not None: + return continuation_prompt_from_package( + context, + native_resume=resume_same_native_session or context.get("resume_mode") == "native", + ) + if native_resume: + if resume_same_native_session: + return dispatcher_child_prompt( + f"{REPOSITORY_LANGUAGE_PROMPT} Continue this session and complete " + "the current task." + ) + target = task.plan or task.directory + return dispatcher_child_prompt( + f"Read {target.resolve()} and complete the task. " + f"{REPOSITORY_LANGUAGE_PROMPT}" + ) + if role == "review": + return dispatcher_child_prompt( + f"Continue the review for {task.directory.resolve()}. " + f"{REPOSITORY_LANGUAGE_PROMPT}" + ) + return dispatcher_child_prompt( + f"Continue from {locator.resolve() if locator else task.directory.resolve()}. Check the saved context and current " + f"workspace. {REPOSITORY_LANGUAGE_PROMPT}" + ) + + +async def run_escalating( + workspace: Path, + store: StateStore, + task: Task, + role: str, + initial: AgentSpec, + initial_resume_locator: Path | None = None, + *, + unchecked_items: bool = False, +) -> tuple[bool, Path | None]: + spec = initial + previous_locator = initial_resume_locator + review_control_retries = 0 + native_recovery_retries = 0 + generic_retries = 0 + terminal_recovery_retries = 0 + native_resume_locator = initial_resume_locator + recovery_failures = 0 + stage_budget: StageFailureBudget | None = None + if isinstance(store, StateStore): + state = store.task_state(task) + persisted = state.get("recovery_failures", {}) + if isinstance(persisted, dict): + recovery_failures = int(persisted.get(role, 0)) + decisions = state.get("execution_decisions", {}) + decision = decisions.get(role) if isinstance(decisions, dict) and role in {"worker", "review"} else None + if isinstance(decision, dict): + stage_budget = StageFailureBudget.from_decision(store, task, decision) + recovery_failures = stage_budget.count() + if recovery_failures >= RECOVERY_FAILURE_LIMIT: + locator = initial_resume_locator + reason = ( + f"{role} recovery failure limit already exhausted: " + f"{recovery_failures}/{RECOVERY_FAILURE_LIMIT}" + ) + if isinstance(store, StateStore): + decision = store.task_state(task).get("execution_decisions", {}).get(role, {}) + store.update_task( + task, + blocked=f"{reason} locator={locator}", + blocker_evidence={ + "role": role, + "failure_class": None, + "locator": str(locator) if locator else None, + "selected": decision.get("selected") if isinstance(decision, dict) else None, + "work_unit_id": decision.get("work_unit_id") if isinstance(decision, dict) else None, + }, + ) + banner( + "작업차단", + task.name, + [ + "reason=recovery-failure-limit", + f"retry={recovery_failures}/{RECOVERY_FAILURE_LIMIT}", + f"locator={locator}", + ], + ) + return False, locator + context: dict[str, Any] | None = None + while True: + prompt = ( + base_prompt(task, role, spec, unchecked_items=unchecked_items) + if previous_locator is None + else continuation_prompt( + task, + role, + previous_locator, + native_resume=spec.native_resume, + resume_same_native_session=native_resume_locator is not None, + context=context, + unchecked_items=unchecked_items, + ) + ) + context = None + rc, failure, locator = await invoke( + workspace, + store, + task, + role, + spec, + prompt, + resume_locator=native_resume_locator, + ) + native_resume_locator = None + if rc == 0 and failure is None: + if isinstance(store, StateStore): + state = store.task_state(task) + persisted = dict(state.get("recovery_failures", {})) + persisted.pop(role, None) + store.update_task(task, recovery_failures=persisted) + if stage_budget is not None: + stage_budget.reset_on_success() + return True, locator + failure = failure or "generic-error" + if failure in { + "work-log-blocked", + "work-log-incomplete", + "work-log-setup", + "work-log-runtime-write", + }: + banner("작업차단", task.name, failure_report_lines(failure, locator)) + return False, locator + recovery_failures += 1 + if stage_budget is not None: + selected = stage_budget.store.task_state(task)["execution_decisions"][role]["selected"] + transition = stage_budget.store.task_state(task)["execution_decisions"][role]["transition"]["trigger"] + recovery_failures = stage_budget.record_failure(target=selected, transition=transition) + if isinstance(store, StateStore): + state = store.task_state(task) + persisted = dict(state.get("recovery_failures", {})) + persisted[role] = recovery_failures + store.update_task(task, recovery_failures=persisted) + if recovery_failures >= RECOVERY_FAILURE_LIMIT: + reason = ( + f"{role} recovery failure limit exhausted: " + f"{recovery_failures}/{RECOVERY_FAILURE_LIMIT}" + ) + if isinstance(store, StateStore): + decision = store.task_state(task).get("execution_decisions", {}).get(role, {}) + store.update_task( + task, + blocked=f"{reason} locator={locator}", + blocker_evidence={ + "role": role, + "failure_class": failure, + "locator": str(locator) if locator else None, + "selected": decision.get("selected") if isinstance(decision, dict) else None, + "work_unit_id": decision.get("work_unit_id") if isinstance(decision, dict) else None, + }, + ) + banner( + "작업차단", + task.name, + [ + "reason=recovery-failure-limit", + *failure_report_lines(failure, locator), + f"retry={recovery_failures}/{RECOVERY_FAILURE_LIMIT}", + ], + ) + return False, locator + if role == "review" and failure == "review-control-violation": + review_control_retries += 1 + banner( + "리뷰재시도", + task.name, + [ + *failure_report_lines(failure, locator), + f"retry={recovery_failures}/{RECOVERY_FAILURE_LIMIT}", + ], + ) + previous_locator = None + await asyncio.sleep(min(30, 2 ** min(review_control_retries, 5))) + continue + current_decision = None + if isinstance(store, StateStore): + task_state = store.task_state(task) + decisions = task_state.get("execution_decisions", {}) + if isinstance(decisions, dict): + current_decision = decisions.get(role) + + if canonical_selector_failover_route(current_decision) and failure in QUALIFIED_FAILOVER_FAILURES: + try: + next_decision = select_execution_decision( + task, + stage=role, + prior_decision=current_decision, + transition="failover", + failure_class=failure, + ) + next_spec = agent_spec_from_decision(next_decision) + if next_spec != spec: + if locator is None: + raise ExecutionDecisionError("logical context locator가 없다") + context = build_context_package( + workspace, task, locator, previous_spec=spec, next_spec=next_spec + ) + commit_execution_decision(store, task, role, next_decision) + banner( + "실행대상전환" if role == "worker" else "리뷰실행대상전환", + task.name, + [ + f"from={spec.display}", + f"to={next_spec.display}", + *failure_report_lines(failure, locator), + ], + ) + spec = next_spec + previous_locator = locator + continue + else: + commit_execution_decision(store, task, role, next_decision) + except (ExecutionDecisionError, OSError, ValueError) as exc: + code = getattr(exc, "code", "") + if not code: + if "no_failover_candidate" in str(exc): + code = "no_failover_candidate" + else: + code = exc.__class__.__name__ + store.update_task( + task, blocked=f"{role} selector decision 실패 [{code}]: {exc}" + ) + banner( + "작업차단", + task.name, + [f"reason={code}", *failure_report_lines(failure, locator)], + ) + return False, locator + if spec.native_resume: + if failure in {"context-limit", "session-stall"}: + native_recovery_retries += 1 + banner( + "native-session세션연속재시작", + task.name, + [ + f"model={spec.display}", + *failure_report_lines(failure, locator), + f"retry={recovery_failures}/{RECOVERY_FAILURE_LIMIT}", + ], + ) + previous_locator = locator + native_resume_locator = locator + await asyncio.sleep(min(30, 2 ** min(native_recovery_retries, 5))) + continue + native_recovery_retries += 1 + if failure == "session-stall": + event = "세션응답복구재시도" + elif failure in { + "provider-connection", + "provider-stream-disconnect", + }: + event = "세션연결재시도" + else: + event = "native-session복구재시도" + banner( + event, + task.name, + [ + f"model={spec.display}", + *failure_report_lines(failure, locator), + f"retry={recovery_failures}/{RECOVERY_FAILURE_LIMIT}", + ], + ) + previous_locator = locator + await asyncio.sleep(min(30, 2 ** min(native_recovery_retries, 5))) + continue + if failure == "generic-error": + generic_retries += 1 + banner( + "작업복구재시도", + task.name, + [ + f"model={spec.display}", + *failure_report_lines(failure, locator), + f"retry={recovery_failures}/{RECOVERY_FAILURE_LIMIT}", + ], + ) + previous_locator = locator + await asyncio.sleep(min(30, 2 ** min(generic_retries, 5))) + continue + if failure not in RECOVERABLE_RUNTIME_FAILURES: + terminal_recovery_retries += 1 + banner( + "모델복구재시도", + task.name, + [ + f"model={spec.display}", + *failure_report_lines(failure, locator), + f"retry={recovery_failures}/{RECOVERY_FAILURE_LIMIT}", + ], + ) + previous_locator = locator + await asyncio.sleep( + min(30, 2 ** min(terminal_recovery_retries, 5)) + ) + continue + if role == "review": + terminal_recovery_retries += 1 + banner( + "리뷰재시도", + task.name, + [ + f"model={spec.display}", + "reason=review-route-has-no-next-target", + *failure_report_lines(failure, locator), + f"retry={recovery_failures}/{RECOVERY_FAILURE_LIMIT}", + ], + ) + previous_locator = locator + await asyncio.sleep( + min(30, 2 ** min(terminal_recovery_retries, 5)) + ) + continue + terminal_recovery_retries += 1 + banner( + "모델복구재시도", + task.name, + [ + f"model={spec.display}", + *failure_report_lines(failure, locator), + f"retry={recovery_failures}/{RECOVERY_FAILURE_LIMIT}", + ], + ) + previous_locator = locator + await asyncio.sleep(min(30, 2 ** min(terminal_recovery_retries, 5))) + + +def task_signature(workspace: Path, task: Task) -> str: + digest = hashlib.sha256() + if not task.directory.exists(): + return "moved" + for path in sorted(p for p in task.directory.iterdir() if p.is_file()): + if ( + PLAN_RE.match(path.name) + or REVIEW_RE.match(path.name) + or path.name.endswith(".log") + ): + digest.update(path.name.encode()) + digest.update(sha256_file(path).encode()) + for raw_path in sorted(task.write_set): + path = Path(raw_path) + path = path if path.is_absolute() else workspace / path + digest.update(raw_path.encode()) + if path.is_file(): + digest.update(str(path.stat().st_mode).encode()) + digest.update(sha256_file(path).encode()) + elif path.exists(): + digest.update(b"non-file") + else: + digest.update(b"missing") + return digest.hexdigest() + + +def read_verdict(path: Path) -> str | None: + if not path.exists(): + return None + return verdict_from_text(path.read_text(encoding="utf-8", errors="replace")) + + +def verdict_from_text(text: str) -> str | None: + selected: tuple[re.Match[str], re.Pattern[str], re.Pattern[str]] | None = None + for heading_re, line_re, block_re in VERDICT_SCHEMA_MATCHERS: + headings = list(heading_re.finditer(text)) + if not headings: + continue + # A duplicated heading, or headings from both schemas, is ambiguous. + if len(headings) != 1 or selected is not None: + return None + selected = (headings[0], line_re, block_re) + if selected is None: + return None + heading, line_re, block_re = selected + next_heading = re.search(r"^##\s+", text[heading.end():], re.MULTILINE) + end = heading.end() + next_heading.start() if next_heading else len(text) + section = text[heading.end():end] + inline_matches = list(line_re.finditer(section)) + block_matches = list(block_re.finditer(section)) + matches = inline_matches + block_matches + return matches[0].group(1) if len(matches) == 1 else None + + +def matching_archive_directories_by_name( + workspace: Path, + task_name: str, + *, + require_complete: bool = True, +) -> list[Path]: + archive = workspace / "agent-task" / "archive" + parts = task_name.split("/") + if not archive.is_dir() or len(parts) not in {1, 2}: + return [] + group = parts[0] + final_name = parts[-1] + suffix_re = re.compile(rf"^{re.escape(final_name)}(?:_\d+)?$") + matches: list[Path] = [] + try: + years = list(archive.iterdir()) + except FileNotFoundError: + return [] + for year in years: + if not year.is_dir(): + continue + try: + months = list(year.iterdir()) + except FileNotFoundError: + continue + for month in months: + if not month.is_dir(): + continue + parent = month if len(parts) == 1 else month / group + if not parent.is_dir(): + continue + try: + candidates = list(parent.iterdir()) + except FileNotFoundError: + continue + for candidate in candidates: + if ( + candidate.is_dir() + and suffix_re.match(candidate.name) + and ( + not require_complete + or (candidate / "complete.log").is_file() + ) + ): + matches.append(candidate) + return sorted(matches) + + +def matching_archive_directories(workspace: Path, task: Task) -> list[Path]: + return matching_archive_directories_by_name(workspace, task.name) + + +def task_group_name(task_name: str) -> str: + return task_name.split("/", 1)[0] + + +def work_log_event_cells(line: str) -> list[str] | None: + stripped = line.strip() + if not stripped.startswith("|") or not stripped.endswith("|"): + return None + cells = [ + cell.strip().replace(r"\|", "|") + for cell in re.split(r"(? str: + """Merge duplicate dispatcher timelines without losing conflicting rows.""" + allowed_metadata = { + "# Milestone Work Log", + "## Dispatcher Timeline", + "> Dispatcher-owned execution timeline. Workers and reviewers do not edit this file.", + "> Dispatcher-owned. Workers and reviewers do not edit this section.", + WORK_LOG_HEADER, + WORK_LOG_SEPARATOR, + LEGACY_WORK_LOG_HEADER, + LEGACY_WORK_LOG_SEPARATOR, + } + unique_rows: dict[tuple[str, ...], tuple[str, ...]] = {} + ordered_rows: list[tuple[str, int, int, list[str]]] = [] + + for source_index, source in enumerate(sorted(sources)): + try: + lines = source.read_text( + encoding="utf-8", + errors="replace", + ).splitlines() + except OSError as exc: + raise ValueError( + f"WORK_LOG source를 읽을 수 없다: source={source} error={exc}" + ) from exc + for line_number, line in enumerate(lines, start=1): + cells = work_log_event_cells(line) + if cells is None: + if not line.strip() or line.strip() in allowed_metadata: + continue + raise ValueError( + "WORK_LOG 병합 대상에 안전하게 보존할 수 없는 내용이 있다: " + f"source={source} line={line_number}" + ) + if cells[0] == "seq" or cells[0].startswith("---"): + continue + if cells[2] not in {"START", "FINISH"}: + raise ValueError( + "WORK_LOG 병합 대상에 지원하지 않는 event가 있다: " + f"source={source} line={line_number} event={cells[2]}" + ) + try: + sequence = int(cells[0]) + int(cells[4]) + int(cells[6]) + except ValueError as exc: + raise ValueError( + "WORK_LOG 병합 대상의 sequence, loop 또는 attempt가 유효하지 않다: " + f"source={source} line={line_number}" + ) from exc + key = (cells[2], cells[3], cells[4], cells[5], cells[6], cells[9]) + fingerprint = tuple(cells[1:]) + previous = unique_rows.get(key) + if previous is not None: + if previous != fingerprint: + raise ValueError( + "WORK_LOG 병합 충돌: " + f"event={cells[2]} task={cells[3]} loop={cells[4]} " + f"role={cells[5]} attempt={cells[6]} locator={cells[9]}" + ) + continue + unique_rows[key] = fingerprint + ordered_rows.append((cells[1], source_index, sequence, cells)) + + if not ordered_rows: + raise ValueError("WORK_LOG 병합 대상에 timeline row가 없다") + + header = ( + "# Milestone Work Log\n\n" + "> Dispatcher-owned execution timeline. Workers and reviewers do not edit this file.\n\n" + f"{WORK_LOG_HEADER}\n" + f"{WORK_LOG_SEPARATOR}\n" + ) + rendered_rows: list[str] = [] + for sequence, (_, _, _, cells) in enumerate(sorted(ordered_rows), start=1): + escaped = [str(value).replace("|", r"\|").replace("\n", " ") for value in cells] + escaped[0] = str(sequence) + rendered_rows.append("| " + " | ".join(escaped) + " |\n") + return header + "".join(rendered_rows) + + +def unfinished_work_log_attempts(path: Path) -> list[dict[str, Any]]: + """Return START rows that have no matching FINISH row.""" + try: + lines = path.read_text( + encoding="utf-8", + errors="replace", + ).splitlines() + except OSError: + raise + open_attempts: dict[str, dict[str, Any]] = {} + for line in lines: + cells = work_log_event_cells(line) + if cells is None or cells[2] not in {"START", "FINISH"}: + continue + try: + sequence = int(cells[0]) + loop = int(cells[4]) + attempt = int(cells[6]) + except ValueError: + continue + locator = cells[9] + key = locator or "\0".join( + (cells[3], cells[4], cells[5], cells[6], cells[7]) + ) + if cells[2] == "START": + open_attempts[key] = { + "sequence": sequence, + "task_name": cells[3], + "loop": loop, + "role": cells[5], + "attempt": attempt, + "model": cells[7], + "locator": locator, + } + else: + open_attempts.pop(key, None) + return sorted( + open_attempts.values(), + key=lambda record: int(record["sequence"]), + ) + + +def close_unfinished_work_log_attempts(path: Path) -> int: + """Close orphaned START rows after verified group completion.""" + unfinished = unfinished_work_log_attempts(path) + for record in unfinished: + locator = Path(str(record["locator"])) + append_work_log_event( + path, + task_name=str(record["task_name"]), + loop=int(record["loop"]), + event="FINISH", + execution_id=f"reconciled-{record['sequence']}", + role=str(record["role"]), + attempt=int(record["attempt"]), + model=str(record["model"]), + result="reconciled:verified-complete-archive", + locator=locator, + ) + return len(unfinished) + + +def archived_task_group_directories( + workspace: Path, + task_group: str, +) -> list[Path]: + """Return month-local archive directories for one logical task group.""" + archive_root = workspace / "agent-task" / "archive" + if not archive_root.is_dir(): + return [] + suffix_re = re.compile(rf"^{re.escape(task_group)}(?:_\d+)?$") + matches: list[Path] = [] + try: + years = list(archive_root.iterdir()) + except FileNotFoundError: + return [] + for year in years: + if not year.is_dir(): + continue + try: + months = list(year.iterdir()) + except FileNotFoundError: + continue + for month in months: + if not month.is_dir(): + continue + try: + candidates = list(month.iterdir()) + except FileNotFoundError: + continue + matches.extend( + candidate + for candidate in candidates + if candidate.is_dir() and suffix_re.fullmatch(candidate.name) + ) + return sorted(matches) + + +def next_work_log_archive_number( + workspace: Path, + task_group: str, +) -> int: + numbers = [ + int(match.group(1)) + for directory in archived_task_group_directories(workspace, task_group) + for path in directory.glob("work_log_*.log") + if (match := WORK_LOG_ARCHIVE_RE.fullmatch(path.name)) + ] + return max(numbers, default=-1) + 1 + + +def completed_group_archive_directory( + task_group: str, + task_names: set[str], + completed_tasks: dict[str, str], +) -> Path | None: + candidates: list[tuple[int, str, Path]] = [] + for task_name in task_names: + archive_raw = completed_tasks.get(task_name) + if not archive_raw: + continue + archive = Path(archive_raw) + if not archive.is_dir(): + continue + complete_log = archive / "complete.log" + target = archive if task_name == task_group else archive.parent + try: + completed_at = ( + complete_log.stat().st_mtime_ns + if complete_log.is_file() + else archive.stat().st_mtime_ns + ) + except OSError: + continue + candidates.append((completed_at, str(target), target)) + return ( + max(candidates, key=lambda item: (item[0], item[1]))[2] + if candidates + else None + ) + + +def archive_completed_group_work_logs( + workspace: Path, + observed_tasks: set[str], + completed_tasks: dict[str, str], + active_or_running: set[str], +) -> tuple[dict[str, str], dict[str, str]]: + """Archive each completed task-group timeline after its last writer exits.""" + observed_by_group: dict[str, set[str]] = {} + for task_name in observed_tasks: + observed_by_group.setdefault(task_group_name(task_name), set()).add( + task_name + ) + active_groups = { + task_group_name(task_name) + for task_name in active_or_running + } + archived: dict[str, str] = {} + errors: dict[str, str] = {} + for task_group, task_names in sorted(observed_by_group.items()): + if task_group in active_groups or not task_names <= set(completed_tasks): + continue + active_source = ( + workspace / "agent-task" / task_group / WORK_LOG_NAME + ) + legacy_sources = { + Path(completed_tasks[task_name]) / WORK_LOG_NAME + for task_name in task_names + if (Path(completed_tasks[task_name]) / WORK_LOG_NAME).is_file() + } + sources = { + *legacy_sources, + *([active_source] if active_source.is_file() else []), + } + if not sources: + continue + target_directory = completed_group_archive_directory( + task_group, + task_names, + completed_tasks, + ) + if target_directory is None: + errors[task_group] = ( + "검증된 task archive에서 WORK_LOG 대상 디렉터리를 정할 수 없다" + ) + continue + archive_number = next_work_log_archive_number( + workspace, + task_group, + ) + destination = target_directory / f"work_log_{archive_number}.log" + source = next(iter(sources)) + if destination.exists(): + errors[task_group] = ( + "WORK_LOG archive destination이 이미 존재한다: " + "sources=" + + ",".join(str(path) for path in sorted(sources)) + + f" destination={destination}" + ) + continue + source = next(iter(sources)) + temporary = destination.with_name(destination.name + ".tmp") + try: + if len(sources) == 1: + close_unfinished_work_log_attempts(source) + source.replace(destination) + else: + if temporary.exists(): + raise OSError( + "WORK_LOG archive temporary destination이 이미 존재한다: " + f"temporary={temporary}" + ) + temporary.write_text( + merge_work_log_sources(sources), + encoding="utf-8", + ) + close_unfinished_work_log_attempts(temporary) + temporary.replace(destination) + for merged_source in sources: + merged_source.unlink() + except (OSError, ValueError) as exc: + if temporary.exists(): + try: + temporary.unlink() + except OSError: + pass + errors[task_group] = ( + "WORK_LOG archive 실패: sources=" + + ",".join(str(path) for path in sorted(sources)) + + " " + f"destination={destination} error={exc}" + ) + continue + if active_source in sources: + for task_name in sorted(task_names, reverse=True): + if "/" not in task_name: + continue + try: + (workspace / "agent-task" / task_name).rmdir() + except OSError: + pass + try: + active_source.parent.rmdir() + except OSError: + pass + archived[task_group] = str(destination.resolve()) + return archived, errors + + +def task_attempt_log_directories(runs: Path, task_name: str) -> list[Path]: + """Return dispatcher-owned attempt directories whose locator names the task.""" + matches: list[Path] = [] + if not runs.is_dir(): + return matches + for attempt_dir in runs.iterdir(): + if not attempt_dir.is_dir(): + continue + locator = attempt_dir / "locator.json" + try: + record = json.loads(locator.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if record.get("task") != task_name: + continue + matches.append(attempt_dir) + return matches + + +def cleanup_completed_task_attempt_logs(runs: Path, task_name: str) -> int: + """Remove only dispatcher-owned logs for a task after its complete archive exists.""" + removed = 0 + for attempt_dir in task_attempt_log_directories(runs, task_name): + try: + shutil.rmtree(attempt_dir) + except OSError as exc: + attempt_event( + "[attempt-log-cleanup-warning]", + f"task={task_name} path={attempt_dir} error={exc}", + ) + continue + removed += 1 + return removed + + +def review_fingerprints(workspace: Path, task: Task) -> set[tuple[str, str]]: + directories = [task.directory] if task.directory.is_dir() else [] + directories.extend(matching_archive_directories(workspace, task)) + fingerprints: set[tuple[str, str]] = set() + for directory in directories: + for path in directory.iterdir(): + if path.is_file() and (path.name.startswith("code_review_") or REVIEW_RE.match(path.name)): + fingerprints.add((str(path.resolve()), sha256_file(path))) + return fingerprints + + +def review_outcome( + workspace: Path, task: Task, prior_fingerprints: set[tuple[str, str]] +) -> dict[str, str]: + if task.directory.is_dir(): + directories = [task.directory] + archives: list[Path] = [] + else: + archives = matching_archive_directories(workspace, task) + directories = list(archives) + newest_directory: Path | None = None + newest_log: Path | None = None + newest_mtime = -1 + for directory in directories: + logs = list(directory.glob("code_review_*.log")) + logs.extend(path for path in directory.iterdir() if path.is_file() and REVIEW_RE.match(path.name)) + for log in logs: + mtime = log.stat().st_mtime_ns + fingerprint = (str(log.resolve()), sha256_file(log)) + if fingerprint not in prior_fingerprints and mtime > newest_mtime and read_verdict(log): + newest_directory = directory + newest_log = log + newest_mtime = mtime + verdict = read_verdict(newest_log) if newest_log else "UNKNOWN" + if newest_directory in archives: + state = "archived" + elif newest_directory and (newest_directory / "USER_REVIEW.md").exists(): + state = "user-review" + elif newest_directory and (newest_directory / "complete.log").exists(): + state = "complete-finalization" + elif newest_log and REVIEW_RE.match(newest_log.name): + state = "finalization-pending" + elif newest_directory and any(REVIEW_RE.match(path.name) for path in newest_directory.iterdir() if path.is_file()): + state = "follow-up" + else: + state = "changed" + return { + "verdict": verdict or "UNKNOWN", + "state": state, + "path": str(newest_directory or task.directory), + "review_log": str(newest_log) if newest_log else "unknown", + } + + +async def run_worker( + workspace: Path, + store: StateStore, + task: Task, + resume_locator: Path | None = None, +) -> None: + retry_context = store.task_state(task).get("retry_failover_context") + if resume_locator is None and isinstance(retry_context, dict): + locator_value = retry_context.get("locator") + if isinstance(locator_value, str) and locator_value: + resume_locator = Path(locator_value) + + # If an active_locator already exists from a prior attempt that wrote its + # locator but crashed before consuming the pending handoff, consume it now + # to prevent a duplicate invocation. The locator write is the durable + # commitment; the pending handoff is the logical intent. Consume the intent + # when the commitment is already present. + if isinstance(store, StateStore): + prior_state = store.task_state(task) + prior_active = prior_state.get("active_locator") + prior_pending = prior_state.get("retry_failover_pending") + if prior_active and prior_pending: + consumed = False + # Prefer handoff_id matching: read the stable identity from the + # active locator file so we can match across crash boundaries + # where the locator path changes. + try: + prior_locator_data = json.loads(Path(prior_active).read_text(encoding="utf-8")) + if isinstance(prior_locator_data, dict): + handoff_id = prior_locator_data.get("retry_handoff_id") + if handoff_id: + consumed = store.commit_retry_handoff_locator( + task, handoff_id, prior_active, + ) + except (OSError, json.JSONDecodeError): + pass + if not consumed: + # Fallback: match by locator path when handoff_id is + # unavailable (e.g. crash between state update and locator + # write, or pre-existing state from a prior dispatcher + # version). + consumed = store.consume_matching_retry_handoff(task, prior_active) + + try: + decision, spec = persisted_execution_decision( + store, task, stage="worker" + ) + except ExecutionDecisionError as exc: + store.update_task(task, blocked=str(exc)) + banner("작업차단", task.name, [f"reason={exc}"]) + return + work_log = milestone_work_log_path(task) + banner( + "작업시작", + task.name, + [ + f"model={spec.display}", + f"plan={task.plan.resolve()}", + f"work_log={work_log.resolve()}", + *task_observation_lines(task), + ], + ) + success, locator = await run_escalating( + workspace, + store, + task, + "worker", + spec, + initial_resume_locator=resume_locator, + ) + if not success: + current = store.task_state(task).get("blocked") + store.update_task( + task, blocked=current or f"worker failure locator={locator}" + ) + return + completed_spec = agent_spec_from_locator(locator) or spec + try: + _mark_worker_done( + store, + task, + initial_decision=decision, + worker_cli=completed_spec.cli, + worker_model=completed_spec.model, + ) + except ExecutionDecisionError as exc: + store.update_task(task, worker_done=False, blocked=f"worker completion validation failed: {exc}") + banner("작업차단", task.name, [f"reason={exc}"]) + return + + +def _require_same_runtime_identity( + expected_spec: AgentSpec, + worker_cli: str, + worker_model: str, +) -> None: + """Verify the worker CLI/model matches the validated completing decision spec. + + Ensures the actual worker that ran is the same runtime identity that the + completing decision authorizes. Prevents a cloud-completed worker from + being recorded as a native-session selfcheck target or vice versa. + """ + if expected_spec.cli != worker_cli: + raise ExecutionDecisionError( + f"worker runtime CLI 불일치: expected={expected_spec.cli} actual={worker_cli}" + ) + if expected_spec.model != worker_model: + raise ExecutionDecisionError( + f"worker runtime model 불일치: expected={expected_spec.model} actual={worker_model}" + ) + + +def _mark_worker_done( + store: StateStore, + task: Task, + *, + initial_decision: dict[str, Any], + worker_cli: str, + worker_model: str, +) -> None: + """Persist worker completion with the authoritative completing decision. + + Uses the persisted execution_decisions worker entry as the sole authoritative + source. Does not fall back to initial_decision even when the persisted + decision is malformed—malformed persisted state blocks completion rather + than silently reverting to a speculative initial decision. + + Validates the completing decision through the strict contract validator + and verifies the worker CLI/model identity matches the normalized spec. + On any validation failure, raises ExecutionDecisionError to prevent + worker_done from being recorded. + """ + decisions = store.task_state(task).get("execution_decisions", {}) + if not isinstance(decisions, dict) or "worker" not in decisions: + raise ExecutionDecisionError( + "persisted worker decision이 execution_decisions에 없다" + ) + decision = decisions["worker"] + if not isinstance(decision, dict): + raise ExecutionDecisionError( + "persisted worker decision이 dict가 아니다" + ) + validated_decision, expected_spec = _validated_completing_decision( + task, decision + ) + _require_same_runtime_identity(expected_spec, worker_cli, worker_model) + selected = validated_decision["selected"] + execution_class = selected["execution_class"] + store.update_task( + task, + worker_done=True, + worker_cli=worker_cli, + worker_model=worker_model, + completing_decision=validated_decision, + execution_class=execution_class, + selfcheck_done=not selected["selfcheck_required"], + blocked=None, + ) + + +async def run_selfcheck( + workspace: Path, + store: StateStore, + task: Task, + resume_locator: Path | None = None, +) -> None: + completing = store.task_state(task).get("completing_decision") + if not isinstance(completing, dict): + store.update_task( + task, blocked="completing decision이 없어 selfcheck를 실행할 수 없다" + ) + banner( + "작업차단", task.name, + ["reason=missing-completing-decision"], + ) + return + try: + _completed_decision, spec = _validated_completing_decision(task, completing) + except ExecutionDecisionError as exc: + store.update_task(task, blocked=str(exc)) + banner("작업차단", task.name, [f"reason={exc}"]) + return + if not spec.selfcheck_required: + raise RuntimeError("selfcheck_required가 아닌 route에 selfcheck stage가 배정됐다") + work_log = milestone_work_log_path(task) + banner( + "자가검증시작", + task.name, + [ + f"model={spec.display}", + f"plan={task.plan.resolve()}", + f"work_log={work_log.resolve()}", + *task_observation_lines(task), + ], + ) + # 0 means the full pass is pending. After it fails the checklist gate, + # each additional count represents one completed unchecked-item retry. + incomplete_results = 0 + if isinstance(store, StateStore): + incomplete_results = int( + store.task_state(task).get("selfcheck_incomplete", 0) + ) + incomplete_retries = max(0, incomplete_results - 1) + if incomplete_retries >= SELF_CHECK_UNCHECKED_RETRY_LIMIT: + locator = resume_locator + reason = ( + "selfcheck unchecked-item retry limit already exhausted: " + f"{incomplete_retries}/{SELF_CHECK_UNCHECKED_RETRY_LIMIT}" + ) + store.update_task(task, blocked=f"{reason} locator={locator}") + banner( + "작업차단", + task.name, + [ + "reason=selfcheck-incomplete-limit", + "mode=unchecked-items", + f"retry={incomplete_retries}/{SELF_CHECK_UNCHECKED_RETRY_LIMIT}", + f"locator={locator}", + ], + ) + return + if incomplete_results > 0 and resume_locator is None: + if not spec.native_resume: + reason = "selfcheck retry에 필요한 native resume 계약이 target에 없다" + store.update_task(task, blocked=reason) + banner( + "작업차단", + task.name, + ["reason=selfcheck-context-unavailable", reason], + ) + return + resume_locator, context_error = selfcheck_context_resume_locator( + store.task_state(task), + task, + expected_workspace=store.workspace, + expected_workspace_id=store.workspace_id, + expected_runs_root=store.runs, + ) + if resume_locator is None: + reason = f"selfcheck context resume 실패: {context_error}" + store.update_task(task, blocked=reason) + banner( + "작업차단", + task.name, + ["reason=selfcheck-context-unavailable", context_error], + ) + return + while True: + unchecked_items = incomplete_results > 0 + success, locator = await run_escalating( + workspace, + store, + task, + "selfcheck", + spec, + initial_resume_locator=resume_locator, + unchecked_items=unchecked_items, + ) + if not success: + current = store.task_state(task).get("blocked") + store.update_task( + task, blocked=current or f"selfcheck failure locator={locator}" + ) + return + errors = implementation_review_errors(task) + if not errors: + break + if not spec.native_resume: + reason = "selfcheck checklist가 미완료지만 target에 native resume 계약이 없다" + store.update_task(task, blocked=reason) + banner( + "작업차단", + task.name, + ["reason=selfcheck-context-unavailable", reason], + ) + return + if locator is None: + reason = "selfcheck 성공 locator가 없어 context를 이어갈 수 없다" + store.update_task(task, blocked=reason) + banner( + "작업차단", + task.name, + ["reason=selfcheck-context-unavailable", reason], + ) + return + incomplete_results += 1 + incomplete_retries = max(0, incomplete_results - 1) + if incomplete_retries >= SELF_CHECK_UNCHECKED_RETRY_LIMIT: + reason = ( + "selfcheck checklist remains incomplete after unchecked-item retry: " + f"{incomplete_retries}/{SELF_CHECK_UNCHECKED_RETRY_LIMIT}" + ) + store.update_task( + task, + blocked=f"{reason} locator={locator}", + selfcheck_incomplete=incomplete_results, + selfcheck_context_locator=str(locator), + ) + banner( + "작업차단", + task.name, + [ + "reason=selfcheck-incomplete-limit", + f"detail={'; '.join(errors)}", + f"retry={incomplete_retries}/{SELF_CHECK_UNCHECKED_RETRY_LIMIT}", + f"locator={locator}", + ], + ) + return + store.update_task( + task, + selfcheck_incomplete=incomplete_results, + selfcheck_context_locator=str(locator), + ) + resume_locator = locator + banner( + "자가검증재시도", + task.name, + [ + f"reason={'; '.join(errors)}", + "mode=unchecked-items", + f"retry={incomplete_retries + 1}/{SELF_CHECK_UNCHECKED_RETRY_LIMIT}", + f"locator={locator}", + ], + ) + store.update_task( + task, + selfcheck_done=True, + selfcheck_incomplete=0, + selfcheck_context_locator=None, + blocked=None, + ) + + +async def run_review( + workspace: Path, + store: StateStore, + task: Task, + resume_locator: Path | None = None, +) -> str | None: + try: + _, spec = persisted_execution_decision( + store, task, stage="review" + ) + except ExecutionDecisionError as exc: + store.update_task(task, blocked=str(exc)) + banner("작업차단", task.name, [f"reason={exc}"]) + return None + state = store.task_state(task) + prior_no_progress = int(state.get("review_no_progress", 0)) + if prior_no_progress >= REVIEW_NO_PROGRESS_LIMIT: + locator = state.get("active_locator") + reason = ( + "review no-progress limit already exhausted: " + f"{prior_no_progress}/{REVIEW_NO_PROGRESS_LIMIT}" + ) + store.update_task(task, blocked=f"{reason} locator={locator}") + banner( + "작업차단", + task.name, + [ + "reason=review-no-progress-limit", + f"unchanged_review_attempts={prior_no_progress}/{REVIEW_NO_PROGRESS_LIMIT}", + f"locator={locator}", + ], + ) + return None + before = task_signature(workspace, task) + prior_review_fingerprints = review_fingerprints(workspace, task) + target = task.review.resolve() if task.review else task.directory.resolve() + banner( + "리뷰시작", + task.name, + [ + f"model={spec.display}", + f"review={target}", + *task_observation_lines(task), + ], + ) + success, locator = await run_escalating( + workspace, + store, + task, + "review", + spec, + initial_resume_locator=resume_locator, + ) + if not success: + current = store.task_state(task).get("blocked") + store.update_task( + task, blocked=current or f"review failure locator={locator}" + ) + return None + after = task_signature(workspace, task) + if before == after: + state = store.task_state(task) + count = int(state.get("review_no_progress", 0)) + 1 + if count >= REVIEW_NO_PROGRESS_LIMIT: + reason = ( + "review made no progress: " + f"{count}/{REVIEW_NO_PROGRESS_LIMIT} locator={locator}" + ) + store.update_task( + task, + review_no_progress=count, + blocked=reason, + ) + banner( + "작업차단", + task.name, + [ + "reason=review-no-progress-limit", + f"unchanged_review_attempts={count}/{REVIEW_NO_PROGRESS_LIMIT}", + f"locator={locator}", + ], + ) + return None + store.update_task(task, review_no_progress=count) + banner( + "루프정체경고", + task.name, + [ + f"unchanged_review_attempts={count}/{REVIEW_NO_PROGRESS_LIMIT}", + f"locator={locator}", + ], + ) + await asyncio.sleep(min(30, count * 5)) + else: + store.update_task(task, review_no_progress=0, blocked=None) + outcome = review_outcome(workspace, task, prior_review_fingerprints) + banner( + "리뷰결과", + task.name, + [ + f"verdict={outcome['verdict']}", + f"state={outcome['state']}", + f"path={outcome['path']}", + f"review_log={outcome['review_log']}", + f"locator={locator}", + ], + ) + if outcome["verdict"] == "PASS" and outcome["state"] == "archived": + banner("작업완료", task.name, [f"archive={outcome['path']}", f"locator={locator}"]) + return outcome["path"] + if outcome["verdict"] == "UNKNOWN" or outcome["state"] == "changed": + raise RuntimeError( + "official review가 판정과 다음 파일 상태를 materialize하지 않았다; " + f"locator={locator}" + ) + if outcome["state"] == "archived": + raise RuntimeError( + f"PASS가 아닌 review가 완료 archive로 이동했다: " + f"verdict={outcome['verdict']} locator={locator}" + ) + return None + + +def status_lines( + task: Task, + stage: str, + dependency: str, + decision: dict[str, Any] | None = None, +) -> list[str]: + route = f"{task.lane}-G{task.grade:02d}" if task.lane and task.grade else "recovery" + base = [f"stage={stage}", f"route={route}", f"dependency={dependency}"] + if decision is not None: + return base + selector_evidence_lines(decision) + return base + + +def select_dispatch_candidates( + store: StateStore, + ready: list[tuple[Task, str]], + *, + persist: bool, + available_slots: int | None = None, +) -> tuple[ + list[tuple[Task, str]], + list[tuple[Task, str, str]], + str, +]: + ready_reviews = [(task, stage) for task, stage in ready if stage == "review"] + ready_workers = [(task, stage) for task, stage in ready if stage in {"worker", "selfcheck"}] + ordered = ready_reviews + ready_workers + claims = store.write_claim_snapshot() + selected: list[tuple[Task, str]] = [] + deferred: list[tuple[Task, str, str]] = [] + timestamp = now_iso() + for task, stage in ordered: + if not task.write_set_known or not task.write_set: + deferred.append( + ( + task, + stage, + "valid non-empty Modified Files Summary write claim이 필요하다", + ) + ) + continue + requested = sorted(task.write_set) + invalid_path: str | None = None + for raw_path in requested: + path = Path(raw_path) + resolved = path.resolve() + try: + resolved.relative_to(store.workspace) + except ValueError: + invalid_path = raw_path + break + if ( + not path.is_absolute() + or str(resolved) != raw_path + or resolved == store.workspace + ): + invalid_path = raw_path + break + if invalid_path is not None: + deferred.append( + ( + task, + stage, + f"write claim 경로가 canonical workspace file이 아니다: {invalid_path}", + ) + ) + continue + + conflict: tuple[str, str] | None = None + requested_set = set(requested) + for owner in sorted(claims): + if owner == task.name: + continue + other = claims[owner] + if other.get("exclusive"): + conflict = (owner, "") + break + intersection = sorted(requested_set & set(other.get("paths", []))) + if intersection: + conflict = (owner, intersection[0]) + break + if conflict is not None: + owner, path = conflict + deferred.append( + ( + task, + stage, + f"write claim 충돌 대기: owner={owner}; path={path}", + ) + ) + continue + + # Capacity-only admission: admit and acquire/replace a claim only + # while a slot remains. A newly capacity-deferred task gets a stable + # wait reason and no new claim; a task that already owns its lifecycle + # claim keeps it unchanged while waiting. + if available_slots is not None and len(selected) >= available_slots: + if task.name in claims: + deferred.append( + ( + task, + stage, + f"capacity waiting: limit reached (selected={len(selected)}/{available_slots})", + ) + ) + else: + deferred.append( + ( + task, + stage, + f"capacity waiting: limit reached (selected={len(selected)}/{available_slots})", + ) + ) + continue + + previous = claims.get(task.name, {}) + claims[task.name] = { + "task": task.name, + "plan_hash": task.plan_hash, + "paths": requested, + "exclusive": False, + "workspace_id": store.workspace_id, + "acquired_at": previous.get("acquired_at") or timestamp, + "updated_at": timestamp, + "source": "plan", + } + selected.append((task, stage)) + + if persist: + store.replace_write_claims(claims, persist=True) + return selected, deferred, "" + + +def ensure_review_shared_state(workspace: Path) -> None: + helper = workspace / "agent-ops" / "bin" / "ai-ignore.sh" + if not helper.is_file(): + raise RuntimeError(f"review shared-state helper가 없다: {helper}") + command = [ + "bash", + "-c", + 'source "$1" && agent_ops_ensure_gitignore_task_artifact_block "$2"', + "agent-task-review-preflight", + str(helper), + str(workspace / ".gitignore"), + ] + completed = subprocess.run( + command, + cwd=workspace, + capture_output=True, + text=True, + check=False, + ) + if completed.returncode != 0: + diagnostic = (completed.stderr or completed.stdout).strip() + raise RuntimeError( + f"review shared-state preflight 실패: {diagnostic or completed.returncode}" + ) + + +async def dispatch(args: argparse.Namespace) -> int: + workspace = Path(args.workspace).resolve() + store = StateStore(workspace) + try: + try: + return await dispatch_with_store(args, workspace, store) + except Exception as exc: + # A scheduler/control-plane exception must not make asyncio.run() + # cancel already-running agent attempts. Keep this loop alive until + # every owned background task finishes naturally; the next + # dispatcher run reconciles their file/state results. + current = asyncio.current_task() + active = [ + task + for task in asyncio.all_tasks() + if task is not current and not task.done() + ] + if active: + banner( + "디스패처복구대기", + args.task_group or "agent-task", + [ + f"running_async_tasks={len(active)}", + "scheduler 예외와 무관하게 실행 중 agent를 자연 종료까지 추적", + ], + ) + await asyncio.gather(*active, return_exceptions=True) + raise DispatcherInterruptedWithActiveWork( + f"running agent가 있던 scheduler 예외: {exc}" + ) from exc + raise + finally: + store.close() + + +async def dispatch_with_store( + args: argparse.Namespace, + workspace: Path, + store: StateStore, +) -> int: + orchestration_scope = args.task_group or "__all__" + if args.retry_blocked and not args.dry_run: + store.mark_retry_failover(args.task_group) + running: dict[str, asyncio.Task[str | None]] = {} + last_wait: dict[str, str] = {} + completed_tasks: dict[str, str] = {} + fatal_errors: dict[str, str] = {} + control_plane_errors: dict[str, str] = {} + work_log_archive_errors: dict[str, str] = {} + review_shared_state_ready = False + candidate_scope: set[str] | None = None + task_cache: dict[str, Task] | None = None + resume_locators: dict[str, Path] = {} + live_external_processes: dict[str, str] = {} + capacity_waiting: set[str] = set() + max_parallel = validated_max_parallel( + getattr(args, "max_parallel", DEFAULT_MAX_PARALLEL) + ) + + while True: + if task_cache is None: + tasks = scan_tasks(workspace, args.task_group) + task_cache = {task.name: task for task in tasks} + else: + tasks = sorted(task_cache.values(), key=lambda task: (task.index, task.name)) + if args.dry_run: + persistent_errors: dict[str, str] = {} + observed_tasks: set[str] = set() + live_external_processes = {} + else: + store.prepare_orchestration(orchestration_scope, tasks, workspace) + live_external_processes = orchestration_live_agent_processes( + store, + orchestration_scope, + ) + active_or_running = ( + {task.name for task in tasks} + | set(running) + | set(live_external_processes) + ) + reconciled_completed, persistent_errors = store.reconcile_orchestration( + orchestration_scope, + workspace, + active_or_running, + ) + completed_tasks.update(reconciled_completed) + for task_name in persistent_errors: + completed_tasks.pop(task_name, None) + observed_tasks = store.orchestration_tasks(orchestration_scope) + work_log_archives, work_log_archive_errors = ( + archive_completed_group_work_logs( + workspace, + observed_tasks, + completed_tasks, + active_or_running, + ) + ) + for task_group, archive in sorted(work_log_archives.items()): + banner( + "작업로그아카이브", + task_group, + [f"archive={archive}"], + ) + if not tasks and not running: + if live_external_processes: + for task_name, detail in sorted( + live_external_processes.items() + ): + banner( + "작업수행중", + task_name, + [ + "이전 dispatcher의 model process를 종료시키지 않고 추적", + detail, + ], + ) + await asyncio.sleep(STREAM_HEARTBEAT_SECONDS) + continue + if control_plane_errors: + banner( + "디스패치추적대기", + args.task_group or "agent-task", + [ + "예상하지 못한 dispatcher 중단 결과를 재조정해야 함", + *( + f"interrupted[{name}]={reason}" + for name, reason in sorted(control_plane_errors.items()) + ), + ], + ) + return 3 + if work_log_archive_errors: + banner( + "디스패치추적대기", + args.task_group or "agent-task", + [ + "완료 task group의 WORK_LOG archive를 재시도해야 함", + *( + f"work-log-archive[{group}]={reason}" + for group, reason in sorted( + work_log_archive_errors.items() + ) + ), + ], + ) + return 3 + if args.task_group and not observed_tasks and not completed_tasks: + reason = ( + "명시한 task group에서 관찰된 active task나 " + "검증된 complete.log 이력이 없다" + ) + if not args.dry_run: + store.mark_orchestration_blocked(orchestration_scope, {}) + banner( + "디스패치차단", + args.task_group, + [f"reason=unobserved-task-group", reason], + ) + return 2 + pending_attempt_logs = { + name: paths + for name in completed_tasks + if ( + paths := task_attempt_log_directories( + store.runs, + name, + ) + ) + } + if pending_attempt_logs: + banner( + "디스패치추적대기", + args.task_group or "agent-task", + [ + "완료 task의 attempt 로그 정리가 아직 끝나지 않음", + *( + f"attempt-log-cleanup-pending[{name}]=" + + ",".join(str(path) for path in paths) + for name, paths in sorted(pending_attempt_logs.items()) + ), + ], + ) + return 3 + incomplete = sorted(observed_tasks - completed_tasks.keys()) + if incomplete or fatal_errors or persistent_errors: + details = [ + *(f"incomplete={name}" for name in incomplete), + *( + f"persistent[{name}]={reason}" + for name, reason in sorted(persistent_errors.items()) + ), + *(f"error[{name}]={reason}" for name, reason in sorted(fatal_errors.items())), + ] + if not args.dry_run: + store.mark_orchestration_blocked( + orchestration_scope, + { + name: ( + "blocked", + persistent_errors.get(name) + or fatal_errors.get(name) + or "관찰된 task가 완료되지 않았다", + ) + for name in ( + set(incomplete) + | set(fatal_errors) + | set(persistent_errors) + ) + }, + ) + banner("디스패치차단", args.task_group or "agent-task", details) + return 2 + if not args.dry_run: + store.mark_orchestration_complete(orchestration_scope) + banner( + "작업완료", + args.task_group or "agent-task", + [ + "active task 없음", + f"verified_complete_tasks={len(completed_tasks)}", + *(f"complete[{name}]={path}" for name, path in sorted(completed_tasks.items())), + ], + ) + return 0 + + task_by_name = {task.name: task for task in tasks} + finished_names: set[str] = set() + complete_log_created = False + for name, future in list(running.items()): + if not future.done(): + continue + finished_names.add(name) + completed_archive: str | None = None + try: + completed_archive = future.result() + if completed_archive: + complete_log_created = True + store.mark_orchestration_task_complete( + orchestration_scope, name, completed_archive + ) + completed_tasks[name] = completed_archive + except Exception as exc: # keep other independent tasks alive + banner( + "디스패치추적대기", + name, + [f"agent coroutine exception={exc}"], + ) + control_plane_errors[name] = str(exc) + task = task_by_name.get(name) + if task: + store.clear_active(task) + if not completed_archive: + refreshed = read_task_directory(workspace, task.directory) + if refreshed is None: + task_cache.pop(name, None) + else: + task_cache[name] = refreshed + if completed_archive: + task_cache.pop(name, None) + del running[name] + if finished_names: + if complete_log_created: + # Running reviewers may be archiving their own active directory + # while this completion-triggered scan runs. Preserve their + # already-loaded Task snapshots and do not reread those mutable + # directories until their futures finish. + running_snapshots = { + name: task_cache[name] + for name in running + if name in task_cache + } + tasks = scan_tasks( + workspace, + args.task_group, + exclude_names=set(running), + ) + task_cache = { + **{task.name: task for task in tasks}, + **running_snapshots, + } + tasks = sorted( + task_cache.values(), + key=lambda task: (task.index, task.name), + ) + store.prepare_orchestration(orchestration_scope, tasks, workspace) + live_external_processes = orchestration_live_agent_processes( + store, + orchestration_scope, + ) + active_or_running = ( + {task.name for task in tasks} + | set(running) + | set(live_external_processes) + ) + reconciled_completed, persistent_errors = store.reconcile_orchestration( + orchestration_scope, + workspace, + active_or_running, + ) + completed_tasks.update(reconciled_completed) + for task_name in persistent_errors: + completed_tasks.pop(task_name, None) + observed_tasks = store.orchestration_tasks(orchestration_scope) + work_log_archives, work_log_archive_errors = ( + archive_completed_group_work_logs( + workspace, + observed_tasks, + completed_tasks, + active_or_running, + ) + ) + for task_group, archive in sorted( + work_log_archives.items() + ): + banner( + "작업로그아카이브", + task_group, + [f"archive={archive}"], + ) + candidate_scope = None + else: + tasks = sorted(task_cache.values(), key=lambda task: (task.index, task.name)) + candidate_scope = finished_names | capacity_waiting + capacity_waiting = set() + + if not tasks and not running: + # A completion-triggered full scan may have removed the last active task. + continue + + # Derive workspace-global capacity. Count unique task names across + # current running futures and same-workspace live/conservative evidence, + # regardless of --task-group. Do not count pump/heartbeat/selector/ + # selector coroutines as extra slots. + workspace_live = workspace_live_agent_processes(store) + workspace_live = { + name: detail + for name, detail in workspace_live.items() + if name not in finished_names + } + occupied_names = set(running) | set(workspace_live) + available_slots: int | None = ( + None if max_parallel == 0 else max(0, max_parallel - len(occupied_names)) + ) + + ready: list[tuple[Task, str]] = [] + waiting_tasks: list[str] = [] + externally_active: list[tuple[Task, str]] = [] + blocked_details: dict[str, tuple[str, str, str]] = {} + for task in tasks: + # A running future owns this task directory. Re-reading its review + # or dependency files can race with review finalization/archive and + # must never interrupt unrelated tasks. + if task.name in running: + continue + if task.name in control_plane_errors: + reason = ( + "예상하지 못한 agent coroutine 중단 결과를 " + "다음 dispatcher가 재조정해야 함: " + f"{control_plane_errors[task.name]}" + ) + blocked_details[task.name] = ( + "디스패치추적대기", + "interrupted", + reason, + ) + waiting_tasks.append(task.name) + continue + state = store.peek_task_state(task) if args.dry_run else store.task_state(task) + active_predecessors = live_predecessors( + task, + set(running) | set(live_external_processes), + ) + if active_predecessors: + dependency_ready = False + dependency = ( + "predecessor FINISH 대기: " + + ",".join(active_predecessors) + ) + else: + dependency_ready, dependency = dependency_state( + workspace, + task, + ) + stage = task_stage(task, state) + if state.get("active_stage"): + active_stage = str(state["active_stage"]) + active_live, active_detail = external_active_is_live( + state, + expected_workspace=store.workspace, + expected_workspace_id=store.workspace_id, + expected_runs_root=store.runs, + ) + if active_live: + reason = f"외부 실행중: stage={active_stage}; {active_detail}" + active_key = ( + f"active|{active_stage}|{state.get('active_locator') or 'unknown'}" + ) + externally_active.append((task, active_stage)) + blocked_details[task.name] = ("작업중", stage, reason) + if not args.dry_run: + store.adopt_active_write_claim(task) + if not args.dry_run and last_wait.get(task.name) != active_key: + banner("작업중", task.name, status_lines(task, stage, reason)) + last_wait[task.name] = active_key + continue + if not args.dry_run: + resume_locator = native_resume_locator( + state, + expected_workspace=store.workspace, + expected_workspace_id=store.workspace_id, + expected_runs_root=store.runs, + ) + if resume_locator is not None: + resume_locators[task.name] = resume_locator + banner( + "작업복구", + task.name, + status_lines(task, stage, f"stale active 제외: {active_detail}"), + ) + store.clear_active(task) + state = store.task_state(task) + reason = "" + if task.errors: + reason = "; ".join(task.errors) + elif task.user_review: + blocking, detail = user_review_blocker_state(task.user_review) + if task.plan is not None or task.review is not None: + blocking = False + detail = "active PLAN/CODE_REVIEW와 공존한다" + reason = ( + f"USER_REVIEW 대기: {task.user_review}; {detail}" + if blocking + else ( + "USER_REVIEW stop 계약 불충족: " + f"{task.user_review}; {detail}" + ) + ) + elif state.get("blocked"): + reason = str(state["blocked"]) + elif not dependency_ready: + reason = dependency + if reason: + wait_key = f"{stage}|{reason}" + event = ( + "작업차단" + if ( + task.errors + or stage in {"blocked", "user-review"} + or state.get("blocked") + ) + else "작업대기" + ) + blocked_details[task.name] = (event, stage, reason) + if not args.dry_run and last_wait.get(task.name) != wait_key: + banner(event, task.name, status_lines(task, stage, reason)) + last_wait[task.name] = wait_key + waiting_tasks.append(task.name) + continue + if task.name not in running and ( + candidate_scope is None or task.name in candidate_scope + ): + ready.append((task, stage)) + + admission_time = datetime.now(UTC) + if args.dry_run: + candidates, deferred, _ = select_dispatch_candidates( + store, + ready, + persist=False, + available_slots=available_slots, + ) + for task, stage, reason in deferred: + event = ( + "작업차단" + if reason.startswith( + ( + "valid non-empty", + "write claim 경로가", + ) + ) + else "작업대기" + ) + blocked_details[task.name] = (event, stage, reason) + waiting_tasks.append(task.name) + ready_by_name = {task.name: stage for task, stage in candidates} + for task in tasks: + if task.name in ready_by_name: + stage = ready_by_name[task.name] + selector_stage = "review" if stage == "review" else "worker" + preview_state = store.peek_task_state(task) + decisions = preview_state.get("execution_decisions", {}) + prior_decision = ( + decisions.get(selector_stage) + if isinstance(decisions, dict) + else None + ) + decision = read_or_preview_stage_decision( + task, + preview_state, + stage=selector_stage, + dry_run=args.dry_run, + ) + spec = agent_spec_from_decision(decision) + lines = status_lines(task, stage, "ready", decision=decision) + banner( + "작업대기", + task.name, + lines + [f"model={spec.display}"], + ) + else: + event, stage, reason = blocked_details[task.name] + banner(event, task.name, status_lines(task, stage, reason)) + return 2 if waiting_tasks and not candidates else 0 + + candidates, deferred, _ = select_dispatch_candidates( + store, + ready, + persist=True, + available_slots=available_slots, + ) + for task, stage, reason in deferred: + event = ( + "작업차단" + if reason.startswith( + ( + "valid non-empty", + "write claim 경로가", + ) + ) + else "작업대기" + ) + blocked_details[task.name] = (event, stage, reason) + waiting_tasks.append(task.name) + wait_key = f"{stage}|{reason}" + if last_wait.get(task.name) != wait_key: + banner(event, task.name, status_lines(task, stage, reason)) + last_wait[task.name] = wait_key + + # Rebuild capacity_waiting from current capacity-only deferrals. + # Dependency, blocker, invalid-write-set, and claim-collision deferrals + # are not capacity waiters and rely on their existing wake-up event. + if available_slots is not None: + capacity_waiting = { + task.name + for task, stage, reason in deferred + if reason.startswith("capacity waiting:") + } + + if ( + not review_shared_state_ready + and any(stage == "review" for _, stage in candidates) + ): + try: + ensure_review_shared_state(workspace) + except (OSError, RuntimeError) as exc: + # Shared review setup is a blocker only for reviews. It must + # not prevent dependency-independent workers/selfchecks from + # starting and draining in the same scheduler pass. + review_removed: list[tuple[Task, str]] = [] + remaining_candidates: list[tuple[Task, str]] = [] + for task, stage in candidates: + if stage != "review": + remaining_candidates.append((task, stage)) + continue + reason = f"review shared-state preflight failed: {exc}" + store.update_task(task, blocked=reason) + fatal_errors[task.name] = reason + if task.name not in waiting_tasks: + waiting_tasks.append(task.name) + blocked_details[task.name] = ( + "작업차단", + stage, + reason, + ) + banner( + "작업차단", + task.name, + status_lines(task, stage, reason), + ) + review_removed.append((task, stage)) + candidates = remaining_candidates + + # Block every ready review that was deferred (e.g. by capacity). + for task, stage, _ in deferred: + if stage == "review": + reason = f"review shared-state preflight failed: {exc}" + store.update_task(task, blocked=reason) + fatal_errors[task.name] = reason + if task.name not in waiting_tasks: + waiting_tasks.append(task.name) + blocked_details[task.name] = ( + "작업차단", + stage, + reason, + ) + banner( + "작업차단", + task.name, + status_lines(task, stage, reason), + ) + + # Refill freed runtime slots from disjoint non-review capacity + # waiters in stable deferred order using persistent claim admission. + # Reviews that had received slots retain their existing claims; + # reviews that never received slots do not synthesize claims. + if available_slots is not None and review_removed: + freed_slots = len(review_removed) + refill_inputs = [ + (task, stage) + for task, stage, reason in deferred + if stage in {"worker", "selfcheck"} + and reason.startswith("capacity waiting:") + ] + if refill_inputs: + refilled, refill_deferred, _ = select_dispatch_candidates( + store, + refill_inputs, + persist=True, + available_slots=freed_slots, + ) + candidates.extend(refilled) + for task, stage, reason in refill_deferred: + event = ( + "작업차단" + if reason.startswith( + ( + "valid non-empty", + "write claim 경로가", + ) + ) + else "작업대기" + ) + blocked_details[task.name] = (event, stage, reason) + wait_key = f"{stage}|{reason}" + if last_wait.get(task.name) != wait_key: + banner(event, task.name, status_lines(task, stage, reason)) + last_wait[task.name] = wait_key + capacity_waiting = { + task.name + for task, stage, reason in refill_deferred + if reason.startswith("capacity waiting:") + } + else: + capacity_waiting = set() + + else: + review_shared_state_ready = True + + scheduled = False + for task, stage in candidates: + store.mark_active(task, stage) + resume_locator = resume_locators.pop(task.name, None) + state = store.task_state(task) + if stage == "review": + future = asyncio.create_task( + run_review( + workspace, + store, + task, + **( + {"resume_locator": resume_locator} + if resume_locator is not None + else {} + ), + ) + ) + elif stage == "selfcheck": + future = asyncio.create_task( + run_selfcheck( + workspace, + store, + task, + **( + {"resume_locator": resume_locator} + if resume_locator is not None + else {} + ), + ) + ) + else: + future = asyncio.create_task( + run_worker( + workspace, + store, + task, + **( + {"resume_locator": resume_locator} + if resume_locator is not None + else {} + ), + ) + ) + running[task.name] = future + last_wait.pop(task.name, None) + scheduled = True + + if running: + await asyncio.wait(running.values(), return_when=asyncio.FIRST_COMPLETED) + continue + if work_log_archive_errors: + banner( + "디스패치추적대기", + args.task_group or "agent-task", + [ + "완료 task group의 WORK_LOG archive를 재시도해야 함", + *( + f"work-log-archive[{group}]={reason}" + for group, reason in sorted( + work_log_archive_errors.items() + ) + ), + ], + ) + return 3 + if externally_active: + banner( + "디스패치추적대기", + args.task_group or "agent-task", + ["새 실행 후보 없음", "active task는 caller가 계속 추적"], + ) + return 3 + if control_plane_errors: + banner( + "디스패치추적대기", + args.task_group or "agent-task", + [ + "실행 중이던 독립 작업을 모두 소진했고 재조정이 필요함", + *( + f"interrupted[{name}]={reason}" + for name, reason in sorted(control_plane_errors.items()) + ), + ], + ) + return 3 + # Capacity-only wait: external live attempts fill the cap, but we must + # not mark the orchestration blocked. Report as non-terminal tracking + # state and return 3 so a restart with a larger limit or after occupancy + # drops can resume naturally. + if capacity_waiting and not scheduled: + external_fillers = set(occupied_names) - set(running) + if external_fillers: + banner( + "디스패치추적대기", + args.task_group or "agent-task", + [ + f"capacity_waiting={','.join(sorted(capacity_waiting))}", + f"occupied_by_external={','.join(sorted(external_fillers))}", + f"max_parallel={max_parallel}", + "용량 대기: 외부 실행이 용량을 채워 다음 dispatcher가 재조정", + ], + ) + return 3 + if not scheduled: + store.mark_orchestration_blocked( + orchestration_scope, + { + name: ( + "blocked" if event == "작업차단" else "waiting", + detail, + ) + for name, (event, _, detail) in blocked_details.items() + }, + ) + reason = f"waiting={','.join(sorted(waiting_tasks))}" + banner( + "디스패치차단", + args.task_group or "agent-task", + [ + "실행 가능한 독립 작업을 모두 소진함", + reason, + f"verified_complete_tasks={len(completed_tasks)}", + *( + f"complete[{name}]={path}" + for name, path in sorted(completed_tasks.items()) + ), + *( + f"{name}: stage={stage}; reason={detail}" + for name, (_, stage, detail) in sorted( + blocked_details.items() + ) + ), + ], + ) + return 2 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--workspace", default=".", help="repository root (default: current directory)") + parser.add_argument("--task-group", help="run only agent-task/") + parser.add_argument( + "--execution-catalog", + help=( + "runtime agent/model catalog JSON; alternatively set " + "AGENT_TASK_EXECUTION_CATALOG" + ), + ) + parser.add_argument("--dry-run", action="store_true", help="classify and print without launching CLIs") + parser.add_argument("--retry-blocked", action="store_true", help="clear dispatcher-local blocked state") + parser.add_argument( + "--max-parallel", + type=int, + default=DEFAULT_MAX_PARALLEL, + metavar="MAX_PARALLEL", + help=( + "physical-workspace global cap on unique active task-stage " + f"attempts; default is {DEFAULT_MAX_PARALLEL}; 0 is unlimited" + ), + ) + parser.add_argument( + "--validate-plan", + metavar="PATH", + help="validate one PLAN Modified Files Summary without starting the dispatcher", + ) + return parser.parse_args() + + +def main() -> int: + global EXECUTION_CATALOG_PATH + args = parse_args() + try: + validated_max_parallel( + getattr(args, "max_parallel", DEFAULT_MAX_PARALLEL) + ) + except ValueError as exc: + print(f"dispatcher error: {exc}", file=sys.stderr) + return 2 + validate_plan = getattr(args, "validate_plan", None) + if validate_plan: + workspace = Path(args.workspace).resolve() + candidate = Path(validate_plan) + if not candidate.is_absolute(): + candidate = (Path.cwd() / candidate).resolve() + metadata_diagnostics = validate_plan_metadata(candidate, workspace) + if metadata_diagnostics: + for diagnostic in metadata_diagnostics: + print(f"plan metadata error: {diagnostic}", file=sys.stderr) + return 2 + write_set, diagnostics = inspect_write_set(candidate, workspace) + if diagnostics: + for diagnostic in diagnostics: + print(f"plan write-set error: {diagnostic}", file=sys.stderr) + return 2 + for path in sorted(write_set): + validation_claim(path) + return 0 + try: + selector = _selector_module() + EXECUTION_CATALOG_PATH = selector.resolve_catalog_path( + getattr(args, "execution_catalog", None) + ) + preflight_execution_catalog( + EXECUTION_CATALOG_PATH, + workspace=Path(args.workspace).resolve(), + run_commands=not args.dry_run, + ) + except Exception as exc: + code = getattr(exc, "code", exc.__class__.__name__) + print(f"dispatcher catalog error [{code}]: {exc}", file=sys.stderr) + return 2 + if os.environ.get(AGENT_PROCESS_MARKER_ENV): + print( + "nested dispatcher invocation rejected: this process is already a " + "dispatcher child; continue the assigned role directly and do not " + "wait for the parent dispatcher", + file=sys.stderr, + ) + return 4 + try: + return asyncio.run(dispatch(args)) + except KeyboardInterrupt: + print("\n중단됨", file=sys.stderr) + return 130 + except DispatcherAlreadyRunning as exc: + print(f"dispatcher active: {exc}", file=sys.stderr) + return 3 + except DispatcherTerminalStateError as exc: + print(f"dispatcher error: {exc}", file=sys.stderr) + return 2 + except Exception as exc: + # An unexpected dispatcher failure is not proof that the task group is + # drained or terminal. The caller must inspect active PIDs/locators and + # recover instead of treating it like exit 2. + print(f"dispatcher interrupted: {exc}", file=sys.stderr) + return 3 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatcher_observation.py b/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatcher_observation.py new file mode 100644 index 0000000..3d2aa6f --- /dev/null +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatcher_observation.py @@ -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) diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/execution_target_policy.py b/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/execution_target_policy.py new file mode 100644 index 0000000..0e0613a --- /dev/null +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/execution_target_policy.py @@ -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), + ) diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/select_execution_target.py b/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/select_execution_target.py new file mode 100644 index 0000000..a5872b0 --- /dev/null +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/select_execution_target.py @@ -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[ \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 = 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()) diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatch.py b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatch.py new file mode 100644 index 0000000..e2cd102 --- /dev/null +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatch.py @@ -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"\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() diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatcher_observation.py b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatcher_observation.py new file mode 100644 index 0000000..817b587 --- /dev/null +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatcher_observation.py @@ -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("\n", encoding="utf-8") + review.write_text("\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() diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_execution_target_policy.py b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_execution_target_policy.py new file mode 100644 index 0000000..c227b99 --- /dev/null +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_execution_target_policy.py @@ -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() diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_select_execution_target.py b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_select_execution_target.py new file mode 100644 index 0000000..ea0f518 --- /dev/null +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_select_execution_target.py @@ -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"\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() diff --git a/agent-ops/skills/common/plan/SKILL.md b/agent-ops/skills/common/plan/SKILL.md index b7eb8e4..112a615 100644 --- a/agent-ops/skills/common/plan/SKILL.md +++ b/agent-ops/skills/common/plan/SKILL.md @@ -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-/` as the task group. Do not include the Phase slug, Epic id, Task id, or a separate task slug in the task group. - `m-` 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 `` by matching exactly one active file at `agent-roadmap/phase/*/milestones/.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 `` by matching exactly one active file at `agent-roadmap/phase/*/milestones/.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 경로의 파일명에서 `.md`를 제거한 값이다. - 같은 Milestone에서 split work가 필요하면 기존 split 규칙 그대로 `agent-task/m-//` 아래에 계획 파일을 만든다. -- Milestone 기능 Task 완료를 목표로 하는 계획이면 `Roadmap Targets` 섹션에 활성 Milestone 경로와 완료 대상 Task id를 고정한다. 이 섹션은 `complete.log`의 `Roadmap Completion` 근거로 복사되어 `update-roadmap`이 해당 Task만 체크하는 anchor가 된다. 이 섹션은 `{task_group}`이 해당 Milestone slug의 `m-`일 때만 쓴다. -- Milestone 작업이 아니거나, Milestone 안의 조사/하위 구현처럼 특정 기능 Task 완료를 주장하지 않는 계획이면 `Roadmap Targets` 섹션을 쓰지 않는다. 섹션이 없으면 PASS 후에도 roadmap Task 체크를 하지 않는다. +- Milestone 작업 계획은 첫 줄에 `milestone-task=[,...]`를 넣어 이 작업이 기여하는 기존 기능 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 + +``` + +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 + ``` 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//milestones/.md` -- Milestone link: [Milestone 문서](agent-roadmap/phase//milestones/.md) -- Task ids: - - ``: -- 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=` 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-/` as the task group; non-roadmap task groups do not start with `m-`. -- Both first lines match ``. +- Both first lines are identical. Non-milestone pairs match ``; `m-*` pairs append exactly ` milestone-task=[,...]` 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-` 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. diff --git a/agent-ops/skills/common/plan/templates/review-stub-template.md b/agent-ops/skills/common/plan/templates/review-stub-template.md index f44a30c..435ba30 100644 --- a/agent-ops/skills/common/plan/templates/review-stub-template.md +++ b/agent-ops/skills/common/plan/templates/review-stub-template.md @@ -1,4 +1,4 @@ - + # 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-`, report completion event metadata. Roadmap state check and `update-roadmap` calls are runtime responsibilities. +4. If PASS and task group is `m-`, 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-`, report completion event metadata for runtime, without modifying roadmap or directly calling `update-roadmap`. +- [ ] If PASS and task group is `m-`, 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 | diff --git a/agent-ops/skills/common/prepare-epic-work-items/SKILL.md b/agent-ops/skills/common/prepare-epic-work-items/SKILL.md new file mode 100644 index 0000000..4a240e9 --- /dev/null +++ b/agent-ops/skills/common/prepare-epic-work-items/SKILL.md @@ -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는 `:`다. +- 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: [] `을 정확히 하나 찾고 그 아래 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하지 않는다. diff --git a/agent-ops/skills/common/prepare-epic-work-items/agents/openai.yaml b/agent-ops/skills/common/prepare-epic-work-items/agents/openai.yaml new file mode 100644 index 0000000..073c5f7 --- /dev/null +++ b/agent-ops/skills/common/prepare-epic-work-items/agents/openai.yaml @@ -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." diff --git a/agent-ops/skills/common/prepare-epic-work-items/scripts/run_agent_once.py b/agent-ops/skills/common/prepare-epic-work-items/scripts/run_agent_once.py new file mode 100644 index 0000000..45a7f41 --- /dev/null +++ b/agent-ops/skills/common/prepare-epic-work-items/scripts/run_agent_once.py @@ -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()) diff --git a/agent-ops/skills/common/prepare-epic-work-items/scripts/run_epic_cycle.py b/agent-ops/skills/common/prepare-epic-work-items/scripts/run_epic_cycle.py new file mode 100755 index 0000000..293caf6 --- /dev/null +++ b/agent-ops/skills/common/prepare-epic-work-items/scripts/run_epic_cycle.py @@ -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()) diff --git a/agent-ops/skills/common/prepare-epic-work-items/tests/test_run_agent_once.py b/agent-ops/skills/common/prepare-epic-work-items/tests/test_run_agent_once.py new file mode 100644 index 0000000..2430da8 --- /dev/null +++ b/agent-ops/skills/common/prepare-epic-work-items/tests/test_run_agent_once.py @@ -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() diff --git a/agent-ops/skills/common/prepare-epic-work-items/tests/test_run_epic_cycle.py b/agent-ops/skills/common/prepare-epic-work-items/tests/test_run_epic_cycle.py new file mode 100644 index 0000000..5362b45 --- /dev/null +++ b/agent-ops/skills/common/prepare-epic-work-items/tests/test_run_epic_cycle.py @@ -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() diff --git a/agent-ops/skills/common/prepare-milestone-workspace/SKILL.md b/agent-ops/skills/common/prepare-milestone-workspace/SKILL.md new file mode 100644 index 0000000..8ab437b --- /dev/null +++ b/agent-ops/skills/common/prepare-milestone-workspace/SKILL.md @@ -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나 상태 파일을 모니터링하지 않는다. diff --git a/agent-ops/skills/common/prepare-milestone-workspace/agents/openai.yaml b/agent-ops/skills/common/prepare-milestone-workspace/agents/openai.yaml new file mode 100644 index 0000000..bde6be4 --- /dev/null +++ b/agent-ops/skills/common/prepare-milestone-workspace/agents/openai.yaml @@ -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." diff --git a/agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_workspace.py b/agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_workspace.py new file mode 100755 index 0000000..6736e67 --- /dev/null +++ b/agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_workspace.py @@ -0,0 +1,1354 @@ +#!/usr/bin/env python3 +"""Prepare one Git Flow feature worktree for an active Milestone.""" + +from __future__ import annotations + +import argparse +import fcntl +import json +import os +from pathlib import Path +import re +import subprocess +import sys +from typing import Any, Iterable, NamedTuple + + +CATALOG_ENV = "AGENT_TASK_EXECUTION_CATALOG" +MILESTONE_PATTERN = re.compile( + r"^agent-roadmap/phase/(?P<phase>[a-z0-9-]+)/milestones/(?P<slug>[a-z0-9-]+)\.md$" +) +EPIC_HEADING = re.compile(r"^### Epic:\s*\[(?P<id>[a-z0-9-]+)\]\s*(?P<title>.+?)\s*$") +TASK_LINE = re.compile(r"^- \[(?P<done>[ xX])\] \[(?P<id>[a-z0-9-]+)\]\s+(?P<body>.+)$") +EPIC_RANGE = re.compile(r"^(?P<start>[1-9][0-9]*)\.\.(?P<end>[1-9][0-9]*)$") +SENSITIVE_PARTS = {".env", "secret", "secrets", "credential", "credentials", "password", "passwords"} +SENSITIVE_SUFFIXES = {".pem", ".key", ".p12"} + + +class PreparationError(RuntimeError): + """A fail-closed preparation error.""" + + +class Epic(NamedTuple): + epic_id: str + title: str + task_ids: tuple[str, ...] + incomplete_ids: tuple[str, ...] + + +def emit(event: str, **payload: Any) -> None: + print(json.dumps({"event": event, **payload}, ensure_ascii=False, sort_keys=True), flush=True) + + +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 PreparationError(f"command failed ({result.returncode}): {' '.join(command)}: {detail}") + return result + + +def git(repo: Path, *arguments: str, check: bool = True) -> str: + result = run(["git", *arguments], cwd=repo, check=check) + return (result.stdout or "").strip() + + +def resolve_repo(raw: str) -> Path: + repo = Path(raw).expanduser().resolve() + if not repo.is_dir(): + raise PreparationError(f"repository directory not found: {repo}") + top = Path(git(repo, "rev-parse", "--show-toplevel")).resolve() + if top != repo: + raise PreparationError(f"--repo must be the repository root: expected={top} actual={repo}") + return repo + + +def resolve_milestone(repo: Path, raw: str) -> tuple[Path, re.Match[str]]: + candidate = Path(raw).expanduser() + path = (repo / candidate).resolve() if not candidate.is_absolute() else candidate.resolve() + try: + relative = path.relative_to(repo).as_posix() + except ValueError as exc: + raise PreparationError(f"milestone is outside repository: {path}") from exc + match = MILESTONE_PATTERN.fullmatch(relative) + if match is None or not path.is_file(): + raise PreparationError(f"active milestone path required: {relative}") + return path, match + + +def section(text: str, heading: str) -> str: + pattern = re.compile( + rf"^## {re.escape(heading)}\s*$\n(?P<body>.*?)(?=^##\s|\Z)", + re.MULTILINE | re.DOTALL, + ) + match = pattern.search(text) + return match.group("body").strip() if match else "" + + +def first_heading(text: str, prefix: str) -> str: + match = re.search(rf"^{re.escape(prefix)}\s*(.+?)\s*$", text, re.MULTILINE) + if not match: + raise PreparationError(f"missing heading: {prefix}") + return match.group(1).strip() + + +def milestone_contract(path: Path) -> dict[str, str]: + text = path.read_text(encoding="utf-8") + status_body = section(text, "상태") + status_match = re.search(r"^\[(.+?)\]\s*$", status_body, re.MULTILINE) + status = status_match.group(1).strip() if status_match else "" + lock = section(text, "구현 잠금") + lock_state = re.search(r"^- 상태:\s*(.+?)\s*$", lock, re.MULTILINE) + decision = re.search(r"^- 결정 필요:\s*(.+?)\s*$", lock, re.MULTILINE) + if status != "계획": + raise PreparationError(f"milestone must be [계획]: actual={status or 'missing'}") + if not lock_state or lock_state.group(1).strip() != "해제": + raise PreparationError("milestone implementation lock is not 해제") + if not decision or decision.group(1).strip() != "없음": + raise PreparationError("milestone has unresolved 결정 필요") + return { + "title": first_heading(text, "# Milestone:"), + "status": status, + } + + +def existing_milestone_contract(path: Path) -> dict[str, str]: + text = path.read_text(encoding="utf-8") + status_body = section(text, "상태") + status_match = re.search(r"^\[(.+?)\]\s*$", status_body, re.MULTILINE) + status = status_match.group(1).strip() if status_match else "" + lock = section(text, "구현 잠금") + lock_state = re.search(r"^- 상태:\s*(.+?)\s*$", lock, re.MULTILINE) + decision = re.search(r"^- 결정 필요:\s*(.+?)\s*$", lock, re.MULTILINE) + if status not in {"계획", "진행중"}: + raise PreparationError( + f"Milestone work requires [계획] or [진행중]: actual={status or 'missing'}" + ) + if not lock_state or lock_state.group(1).strip() != "해제": + raise PreparationError("milestone implementation lock is not 해제") + if not decision or decision.group(1).strip() != "없음": + raise PreparationError("milestone has unresolved 결정 필요") + return { + "title": first_heading(text, "# Milestone:"), + "status": status, + } + + +def parse_epics(text: str) -> list[Epic]: + lines = text.splitlines() + starts: list[tuple[int, re.Match[str]]] = [] + for index, line in enumerate(lines): + match = EPIC_HEADING.fullmatch(line) + if match: + starts.append((index, match)) + epics: list[Epic] = [] + for position, (start, match) in enumerate(starts): + end = starts[position + 1][0] if position + 1 < len(starts) else len(lines) + task_matches = [ + value + for value in (TASK_LINE.fullmatch(line) for line in lines[start + 1 : end]) + if value is not None + ] + epics.append( + Epic( + epic_id=match.group("id"), + title=match.group("title"), + task_ids=tuple(value.group("id") for value in task_matches), + incomplete_ids=tuple( + value.group("id") for value in task_matches if value.group("done") == " " + ), + ) + ) + return epics + + +def select_epics(epics: list[Epic], selector: str) -> list[Epic]: + if not epics: + raise PreparationError("target Milestone has no Epic") + if selector == "remaining": + return [epic for epic in epics if epic.incomplete_ids] + if selector == "first-incomplete": + selected = next((epic for epic in epics if epic.incomplete_ids), None) + if selected is None: + raise PreparationError("target Milestone has no incomplete Epic") + return [selected] + range_match = EPIC_RANGE.fullmatch(selector) + if range_match: + start = int(range_match.group("start")) + end = int(range_match.group("end")) + if start > end: + raise PreparationError(f"Epic range start must not exceed end: {selector}") + if end > len(epics): + raise PreparationError( + f"Epic range exceeds document order: requested={selector} available={len(epics)}" + ) + return epics[start - 1 : end] + selected: list[Epic] = [] + for raw in selector.split(","): + value = raw.strip() + if not value: + raise PreparationError("--epics contains an empty selector") + matches = [ + epic + for epic in epics + if epic.epic_id == value or epic.title.casefold().strip() == value.casefold() + ] + if len(matches) != 1: + raise PreparationError(f"Epic selector must resolve exactly once: {value}") + if matches[0] in selected: + raise PreparationError(f"duplicate Epic selector: {value}") + selected.append(matches[0]) + return [epic for epic in epics if epic in selected] + + +def resolve_workspace(repo: Path, raw: str) -> Path: + candidate = Path(raw).expanduser() + return (repo / candidate).resolve() if not candidate.is_absolute() else candidate.resolve() + + +def phase_contract(repo: Path, phase_slug: str) -> dict[str, str]: + path = repo / "agent-roadmap" / "phase" / phase_slug / "PHASE.md" + if not path.is_file(): + raise PreparationError(f"phase document not found: {path}") + text = path.read_text(encoding="utf-8") + status_body = section(text, "상태") + status_match = re.search(r"^\[(.+?)\]\s*$", status_body, re.MULTILINE) + return { + "title": first_heading(text, "# Phase:"), + "status": status_match.group(1).strip() if status_match else "계획", + } + + +def git_common_dir(repo: Path) -> Path: + raw = Path(git(repo, "rev-parse", "--git-common-dir")) + return (repo / 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_json(path: Path) -> dict[str, Any] | None: + if not path.exists(): + return None + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise PreparationError(f"invalid state file: {path}: {exc}") from exc + if not isinstance(value, dict): + raise PreparationError(f"state file must contain an object: {path}") + return value + + +def process_start_token(pid: int) -> str | None: + stat = Path(f"/proc/{pid}/stat") + try: + remainder = stat.read_text(encoding="utf-8").rsplit(")", 1)[1].split() + return f"proc:{remainder[19]}" + except (OSError, IndexError): + return None + + +def process_is_same(pid: object, expected_token: object) -> bool: + if not isinstance(pid, int) or pid <= 0: + return False + try: + os.kill(pid, 0) + except (OSError, ValueError): + return False + actual_token = process_start_token(pid) + if expected_token is None or actual_token is None: + return True + return actual_token == expected_token + + +def changed_paths(workspace: Path) -> list[str]: + unmerged = git(workspace, "diff", "--name-only", "--diff-filter=U") + if unmerged: + raise PreparationError(f"workspace has unmerged paths: {','.join(unmerged.splitlines())}") + values: set[str] = set() + for arguments in ( + ("diff", "--name-only", "--no-renames", "-z", "HEAD"), + ("ls-files", "--others", "--exclude-standard", "-z"), + ): + result = subprocess.run( + ["git", *arguments], + cwd=workspace, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode != 0: + raise PreparationError(result.stderr.decode("utf-8", "replace").strip()) + values.update( + part.decode("utf-8", "surrogateescape") + for part in result.stdout.split(b"\0") + if part + ) + return sorted(values) + + +def sensitive(path: str) -> bool: + candidate = Path(path) + lowered = {part.casefold() for part in candidate.parts} + if lowered & SENSITIVE_PARTS: + return True + name = candidate.name.casefold() + return ( + any(token in name for token in ("secret", "credential", "password")) + or candidate.suffix.casefold() in SENSITIVE_SUFFIXES + ) + + +def ref_exists(repo: Path, ref: str) -> bool: + return run(["git", "show-ref", "--verify", "--quiet", ref], cwd=repo, check=False).returncode == 0 + + +def ensure_clean(repo: Path, label: str) -> None: + status = git(repo, "status", "--porcelain=v1", "--untracked-files=all") + if status: + raise PreparationError(f"{label} has repository changes; clean checkout required") + + +def worktrees(repo: Path) -> list[dict[str, str]]: + records: list[dict[str, str]] = [] + current: dict[str, str] = {} + for line in git(repo, "worktree", "list", "--porcelain").splitlines() + [""]: + if not line: + if current: + records.append(current) + current = {} + continue + key, _, value = line.partition(" ") + current[key] = value + return records + + +def probe_targets( + repo: Path, + execution_catalog: str, + planner_target: str, + review_target: str, +) -> None: + runner = ( + Path(__file__).resolve().parents[2] + / "prepare-epic-work-items" + / "scripts" + / "run_agent_once.py" + ) + if not runner.is_file(): + raise PreparationError(f"agent runner not found: {runner}") + seen: set[str] = set() + for target_id in (planner_target, review_target): + if target_id in seen: + continue + seen.add(target_id) + command = [ + sys.executable, + str(runner), + "--execution-catalog", + execution_catalog, + "--target-id", + target_id, + "--workspace", + str(repo), + "--probe", + ] + result = run(command, cwd=repo, check=False, capture=False) + if result.returncode != 0: + raise PreparationError( + f"execution target capability probe failed: target_id={target_id}" + ) + + +def render_current( + *, + phase_slug: str, + phase: dict[str, str], + milestone_slug: str, + milestone: dict[str, str], +) -> str: + return f"""# 현재 로드맵 컨텍스트 + +## 고정 참조 + +- Phase를 가로지르는 다음 작업 후보는 [전역 마일스톤 실행 순서](priority-queue.md)를 먼저 확인한다. +- Phase는 도메인/책임 영역이며 순차 실행 게이트가 아니다. + +## 활성 Phase + +- [{phase['status']}] {phase['title']} + - 경로: [PHASE.md](phase/{phase_slug}/PHASE.md) + +## 활성 Milestone + +- [{milestone['status']}] {milestone['title']} + - Phase: [PHASE.md](phase/{phase_slug}/PHASE.md) + - 경로: [{milestone['title']}](phase/{phase_slug}/milestones/{milestone_slug}.md) + +## 선택 규칙 + +- 이 문서는 현재 feature workspace의 활성 Phase와 Milestone 후보를 가리킨다. +- 실제 진행·완료 상태는 Milestone 문서와 active task evidence로 판정한다. +- `[완료]` 또는 `[폐기]` 항목은 활성 항목에 남기지 않는다. +""" + + +def epic_cycle_script(workspace: Path) -> Path: + path = ( + workspace + / "agent-ops" + / "skills" + / "common" + / "prepare-epic-work-items" + / "scripts" + / "run_epic_cycle.py" + ) + if not path.is_file(): + raise PreparationError(f"Epic cycle script not found: {path}") + return path + + +def dispatcher_script(workspace: Path) -> Path: + root = workspace / "agent-ops" / "skills" / "common" / "orchestrate-agent-task-loop" + path = root / "scripts" / "dispatch.py" + if not path.is_file(): + raise PreparationError(f"dispatcher script not found: {path}") + return path + + +def epic_cycle_command( + *, + args: argparse.Namespace, + workspace: Path, + milestone: Path, + epic: Epic, + batch_ids: list[str], + validate_only: bool = False, +) -> list[str]: + command = [ + sys.executable, + str(epic_cycle_script(workspace)), + "--workspace", + str(workspace), + "--milestone", + str(milestone), + "--epic", + epic.epic_id, + "--execution-catalog", + args.execution_catalog, + "--planner-target", + args.planner_target, + "--batch-task-ids", + ",".join(batch_ids), + ] + if args.review_target: + command.extend(["--review-target", args.review_target]) + if validate_only: + command.append("--validate-only") + elif args.retry: + command.append("--retry") + return command + + +def validate_batch( + *, + args: argparse.Namespace, + workspace: Path, + milestone: Path, + epics: list[Epic], + batch_ids: list[str], +) -> None: + for epic in epics: + result = run( + epic_cycle_command( + args=args, + workspace=workspace, + milestone=milestone, + epic=epic, + batch_ids=batch_ids, + validate_only=True, + ), + cwd=workspace, + check=False, + capture=False, + ) + if result.returncode != 0: + raise PreparationError( + f"selected Epic batch validation failed: epic={epic.epic_id} exit={result.returncode}" + ) + + +def publish_batch_review(workspace: Path) -> str: + paths = changed_paths(workspace) + if paths: + unsafe = [path for path in paths if sensitive(path)] + if unsafe: + raise PreparationError(f"sensitive path refused: {','.join(unsafe)}") + for path in paths: + git(workspace, "add", "--", path) + git(workspace, "diff", "--cached", "--check") + git(workspace, "commit", "-m", "chore(milestone): 복수 Epic 준비 결과를 검증한다") + git(workspace, "push") + if changed_paths(workspace): + raise PreparationError("workspace is dirty after batch review publish") + return git(workspace, "rev-parse", "HEAD") + + +def cross_epic_review( + *, + args: argparse.Namespace, + workspace: Path, + milestone: Path, + milestone_slug: str, + phase_slug: str, + epics: list[Epic], + batch_ids: list[str], + common: Path, +) -> tuple[int, dict[str, Any] | None]: + reviewer_target = args.review_target or args.planner_target + state_root = common / "milestone-work-preparation" / milestone_slug + prompt_path = state_root / "prompts" / "cross-epic-review.txt" + result_path = ( + common + / "epic-work-preparation" + / milestone_slug + / "_batch" + / "cross-epic-review.json" + ) + label = f"{milestone_slug}-cross-epic-review" + selected = "\n".join( + f"- [{epic.epic_id}] {epic.title}: {','.join(epic.task_ids)}" for epic in epics + ) + prompt = f"""You are a fresh child agent launched for one bounded cross-Epic preparation review. +Work only in {workspace}. +Read the repository AGENTS.md completely, then read agent-ops/skills/common/router.md and only the plan/refine skills required to correct artifacts. +Do not start subagents, orchestration dispatchers, preparation scripts, or monitoring loops. +Do not commit or push; the parent runtime owns the checkpoint. +Target Milestone: {milestone} +Selected Epics in document order: +{selected} +Allowed Milestone Task ids: {','.join(batch_ids)} + +Review the complete prepared artifact union across these Epics from a fresh context. Check scope coverage, cross-Epic assumptions and dependencies, write-set collisions, PLAN/CODE_REVIEW pairing and first-line metadata, task ids, indices, routing, and verification. Fix every material defect through the owning plan/refine procedure without adding an official code-review verdict. Do not implement active PLAN work. If a genuine product or scope decision is required, create the Milestone SDD USER_REVIEW artifact and stop without inventing a decision. Final in Korean. +""" + prompt_path.parent.mkdir(parents=True, exist_ok=True) + prompt_path.write_text(prompt, encoding="utf-8") + previous = read_json(result_path) + if previous is not None: + if previous.get("workspace") != str(workspace) or previous.get("label") != label: + raise PreparationError(f"cross-Epic review result identity mismatch: {result_path}") + if previous.get("status") == "succeeded": + emit( + "AGENT_RESULT_RECOVERED", + stage="cross-epic-review", + locator=previous.get("locator"), + result="succeeded", + ) + elif previous.get("status") in {"running", "tracking"} and process_is_same( + previous.get("agent_pid"), previous.get("agent_process_start_token") + ): + emit( + "AGENT_TRACKING", + stage="cross-epic-review", + locator=previous.get("locator"), + pid=previous.get("agent_pid"), + ) + return 3, previous + elif not args.retry: + emit( + "AGENT_RECOVERY_REQUIRED", + stage="cross-epic-review", + locator=previous.get("locator"), + action="inspect locator, then rerun with --retry", + ) + return 3, previous + record = previous + if record is None or record.get("status") != "succeeded": + runner = ( + workspace + / "agent-ops" + / "skills" + / "common" + / "prepare-epic-work-items" + / "scripts" + / "run_agent_once.py" + ) + command = [ + sys.executable, + str(runner), + "--execution-catalog", + args.execution_catalog, + "--target-id", + reviewer_target, + "--workspace", + str(workspace), + "--prompt-file", + str(prompt_path), + "--label", + label, + "--result-file", + str(result_path), + ] + starting_head = git(workspace, "rev-parse", "HEAD") + result = run(command, cwd=workspace, check=False, capture=False) + if git(workspace, "rev-parse", "HEAD") != starting_head: + raise PreparationError("cross-Epic reviewer committed unexpectedly") + record = read_json(result_path) + if result.returncode != 0 or record is None or record.get("status") != "succeeded": + return (3 if result.returncode == 3 else 2), record + review_path = ( + workspace + / "agent-roadmap" + / "sdd" + / phase_slug + / milestone_slug + / "USER_REVIEW.md" + ) + if review_path.exists(): + head = publish_batch_review(workspace) + emit("USER_REVIEW", path=str(review_path), head=head) + return 2, record + task_group = workspace / "agent-task" / f"m-{milestone_slug}" + if list(task_group.glob("USER_REVIEW.md")) + list(task_group.glob("*/USER_REVIEW.md")): + raise PreparationError("cross-Epic reviewer created forbidden agent-task USER_REVIEW") + validate_batch( + args=args, + workspace=workspace, + milestone=milestone, + epics=epics, + batch_ids=batch_ids, + ) + head = publish_batch_review(workspace) + emit("CROSS_EPIC_REVIEW_FINISHED", head=head, epics=[epic.epic_id for epic in epics]) + return 0, record + + +def coordinate_batch( + *, + args: argparse.Namespace, + workspace: Path, + milestone: Path, + milestone_slug: str, + phase_slug: str, + common: Path, +) -> int: + selected = select_epics(parse_epics(milestone.read_text(encoding="utf-8")), args.epics) + empty = [epic.epic_id for epic in selected if not epic.task_ids] + if empty: + raise PreparationError(f"selected Epic has no Task ids: {','.join(empty)}") + batch_ids = [task_id for epic in selected for task_id in epic.task_ids] + duplicates = sorted({value for value in batch_ids if batch_ids.count(value) > 1}) + if duplicates: + raise PreparationError(f"selected Epic Task ids are not unique: {','.join(duplicates)}") + identity = { + "milestone": str(milestone), + "workspace": str(workspace), + "selected_epics": [epic.epic_id for epic in selected], + "batch_task_ids": batch_ids, + "execution_catalog": str(Path(args.execution_catalog).expanduser().resolve()), + "planner_target": args.planner_target, + "review_target": args.review_target, + } + state_path = common / "milestone-work-preparation" / milestone_slug / "batch-state.json" + state = read_json(state_path) + if state is None: + state = { + **identity, + "status": "active", + "epic_events": {}, + "cross_epic_review_done": False, + "dispatcher_dry_run_done": False, + "dispatcher_live_started": False, + } + atomic_json(state_path, state) + else: + mismatched = [key for key, expected in identity.items() if state.get(key) != expected] + if mismatched and state.get("status") == "completed": + state = { + **identity, + "status": "active", + "epic_events": {}, + "cross_epic_review_done": False, + "dispatcher_dry_run_done": False, + "dispatcher_live_started": False, + } + atomic_json(state_path, state) + elif mismatched: + key = mismatched[0] + raise PreparationError( + f"active Milestone preparation batch identity changed: field={key} " + f"state={state.get(key)} requested={identity[key]}" + ) + if state.get("status") == "completed": + emit( + "MILESTONE_PREPARATION_COMPLETED", + milestone=milestone_slug, + epics=identity["selected_epics"], + resumed=True, + head=state.get("head"), + ) + return 0 + resume_blocked_dispatcher = state.get("status") == "dispatcher-blocked" + if state.get("status") == "dispatching" and process_is_same( + state.get("dispatcher_pid"), state.get("dispatcher_process_start_token") + ): + emit( + "DISPATCHER_TRACKING", + pid=state.get("dispatcher_pid"), + task_group=f"m-{milestone_slug}", + ) + return 3 + if state.get("status") in {"dispatching", "dispatcher-tracking"} and not args.retry: + emit( + "DISPATCHER_RECOVERY_REQUIRED", + task_group=f"m-{milestone_slug}", + action="inspect dispatcher state, then rerun with --retry", + ) + return 3 + if state.get("status") == "dispatcher-blocked" and not args.retry: + emit( + "DISPATCHER_BLOCKED", + task_group=f"m-{milestone_slug}", + exit_code=state.get("dispatcher_exit_code"), + resumed=True, + ) + return 2 + + epic_events = state.get("epic_events") + if not isinstance(epic_events, dict): + raise PreparationError(f"invalid Epic event map: {state_path}") + unknown_events = sorted(set(epic_events) - set(identity["selected_epics"])) + if unknown_events: + raise PreparationError(f"Epic event map contains unselected ids: {','.join(unknown_events)}") + for epic in selected: + if epic_events.get(epic.epic_id) in {"EPIC_WORK_ITEMS_READY", "EPIC_COMPLETED"}: + continue + state.update(status="epic-preparing", current_epic=epic.epic_id) + atomic_json(state_path, state) + result = run( + epic_cycle_command( + args=args, + workspace=workspace, + milestone=milestone, + epic=epic, + batch_ids=batch_ids, + ), + cwd=workspace, + check=False, + capture=False, + ) + epic_state_path = ( + common + / "epic-work-preparation" + / milestone_slug + / epic.epic_id + / "state.json" + ) + epic_state = read_json(epic_state_path) or {} + terminal = epic_state.get("event") + if result.returncode != 0 or terminal not in {"EPIC_WORK_ITEMS_READY", "EPIC_COMPLETED"}: + status = epic_state.get("status") + if terminal == "USER_REVIEW" or status == "user-review": + state.update(status="user-review", event="USER_REVIEW", current_epic=epic.epic_id) + atomic_json(state_path, state) + return 2 + if result.returncode == 3 or status == "tracking": + state.update(status="epic-tracking", event="AGENT_TRACKING", current_epic=epic.epic_id) + atomic_json(state_path, state) + return 3 + state.update( + status="failed", + event="FAILED", + current_epic=epic.epic_id, + reason=epic_state.get("reason", f"Epic cycle exit={result.returncode}"), + ) + atomic_json(state_path, state) + return 2 + epic_events[epic.epic_id] = terminal + state.update(status="active", epic_events=epic_events) + state.pop("current_epic", None) + atomic_json(state_path, state) + + if len(selected) > 1 and not state.get("cross_epic_review_done"): + state.update(status="cross-epic-review") + atomic_json(state_path, state) + result, record = cross_epic_review( + args=args, + workspace=workspace, + milestone=milestone, + milestone_slug=milestone_slug, + phase_slug=phase_slug, + epics=selected, + batch_ids=batch_ids, + common=common, + ) + if result != 0: + review_path = ( + workspace + / "agent-roadmap" + / "sdd" + / phase_slug + / milestone_slug + / "USER_REVIEW.md" + ) + user_review = result == 2 and review_path.exists() + state.update( + status=( + "user-review" + if user_review + else "cross-epic-review-tracking" + if result == 3 + else "failed" + ), + event=( + "USER_REVIEW" + if user_review + else "AGENT_TRACKING" + if result == 3 + else "FAILED" + ), + review_locator=record.get("locator") if record else None, + ) + atomic_json(state_path, state) + return result + state.update( + status="active", + cross_epic_review_done=True, + review_locator=record.get("locator") if record else None, + ) + atomic_json(state_path, state) + + validate_batch( + args=args, + workspace=workspace, + milestone=milestone, + epics=selected, + batch_ids=batch_ids, + ) + refreshed_epics = { + epic.epic_id: epic for epic in parse_epics(milestone.read_text(encoding="utf-8")) + } + for epic_id in identity["selected_epics"]: + refreshed = refreshed_epics.get(epic_id) + if refreshed is None: + raise PreparationError(f"selected Epic disappeared before batch barrier: {epic_id}") + if not refreshed.incomplete_ids: + epic_events[epic_id] = "EPIC_COMPLETED" + state.update( + status="batch-ready", + event="MILESTONE_WORK_ITEMS_READY", + epic_events=epic_events, + ) + atomic_json(state_path, state) + emit( + "MILESTONE_WORK_ITEMS_READY", + milestone=milestone_slug, + epics=[epic.epic_id for epic in selected], + terminals=epic_events, + ) + + if all(event == "EPIC_COMPLETED" for event in epic_events.values()): + state.update( + status="completed", + event="MILESTONE_PREPARATION_COMPLETED", + dispatcher="skipped-no-active-plans", + head=git(workspace, "rev-parse", "HEAD"), + ) + atomic_json(state_path, state) + emit( + "MILESTONE_PREPARATION_COMPLETED", + milestone=milestone_slug, + epics=[epic.epic_id for epic in selected], + dispatcher="skipped-no-active-plans", + ) + return 0 + + dispatcher = dispatcher_script(workspace) + task_group = f"m-{milestone_slug}" + if not state.get("dispatcher_dry_run_done"): + dry_run = run( + [ + sys.executable, + str(dispatcher), + "--workspace", + str(workspace), + "--task-group", + task_group, + "--execution-catalog", + args.execution_catalog, + "--dry-run", + ], + cwd=workspace, + check=False, + capture=False, + ) + if dry_run.returncode != 0: + state.update(status="failed", event="FAILED", reason=f"dispatcher dry-run exit={dry_run.returncode}") + atomic_json(state_path, state) + return 2 + state.update(dispatcher_dry_run_done=True, dispatcher=str(dispatcher)) + atomic_json(state_path, state) + emit("DISPATCHER_DRY_RUN_FINISHED", task_group=task_group) + + command = [ + sys.executable, + str(dispatcher), + "--workspace", + str(workspace), + "--task-group", + task_group, + "--execution-catalog", + args.execution_catalog, + ] + if resume_blocked_dispatcher and args.retry: + command.append("--retry-blocked") + try: + process = subprocess.Popen(command, cwd=workspace, start_new_session=True) + except OSError as exc: + raise PreparationError(f"dispatcher launch failed: {exc}") from exc + state.update( + status="dispatching", + event="DISPATCHER_STARTED", + dispatcher_live_started=True, + dispatcher_pid=process.pid, + dispatcher_process_start_token=process_start_token(process.pid), + dispatcher_command=command, + ) + atomic_json(state_path, state) + emit("DISPATCHER_STARTED", task_group=task_group, pid=process.pid) + try: + exit_code = process.wait() + except KeyboardInterrupt: + state.update(status="dispatcher-tracking", event="DISPATCHER_TRACKING") + atomic_json(state_path, state) + emit("DISPATCHER_TRACKING", task_group=task_group, pid=process.pid) + return 3 + state.update(dispatcher_exit_code=exit_code) + if exit_code == 3: + state.update(status="dispatcher-tracking", event="DISPATCHER_TRACKING") + atomic_json(state_path, state) + emit("DISPATCHER_TRACKING", task_group=task_group, exit_code=exit_code) + return 3 + if exit_code != 0: + state.update(status="dispatcher-blocked", event="DISPATCHER_BLOCKED") + atomic_json(state_path, state) + emit("DISPATCHER_BLOCKED", task_group=task_group, exit_code=exit_code) + return 2 + + validate_batch( + args=args, + workspace=workspace, + milestone=milestone, + epics=selected, + batch_ids=batch_ids, + ) + refreshed = {epic.epic_id: epic for epic in parse_epics(milestone.read_text(encoding="utf-8"))} + incomplete = [ + epic_id + for epic_id in identity["selected_epics"] + if epic_id not in refreshed or refreshed[epic_id].incomplete_ids + ] + if incomplete: + raise PreparationError( + "dispatcher exited successfully but selected Epic workstate is incomplete: " + + ",".join(incomplete) + ) + state.update( + status="completed", + event="MILESTONE_PREPARATION_COMPLETED", + head=git(workspace, "rev-parse", "HEAD"), + ) + atomic_json(state_path, state) + emit( + "MILESTONE_PREPARATION_COMPLETED", + milestone=milestone_slug, + epics=identity["selected_epics"], + dispatcher_exit_code=exit_code, + head=state["head"], + ) + return 0 + + +def parser() -> argparse.ArgumentParser: + value = argparse.ArgumentParser(description=__doc__) + value.add_argument("--repo") + value.add_argument("--milestone", required=True) + value.add_argument("--workspace") + value.add_argument( + "--existing-workspace", + action="store_true", + help="start selected Epic work in the current prepared feature workspace", + ) + value.add_argument("--execution-catalog", default=os.environ.get(CATALOG_ENV)) + 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( + "--epics", + help="prepare and dispatch remaining/first-incomplete/one/list/range selector", + ) + value.add_argument( + "--retry", + action="store_true", + help="resume a stopped batch after its recorded recovery condition was handled", + ) + value.add_argument("--remote", default="origin") + value.add_argument( + "--skip-agent-probe", + action="store_true", + help="tests only; requires --dry-run or AGENT_OPS_TESTING=1", + ) + value.add_argument("--dry-run", action="store_true") + return value + + +def apply_defaults(args: argparse.Namespace) -> argparse.Namespace: + if not args.execution_catalog: + raise PreparationError( + f"--execution-catalog or {CATALOG_ENV} is required" + ) + if not args.planner_target: + raise PreparationError( + "--planner-target or AGENT_TASK_PLANNER_TARGET is required" + ) + args.execution_catalog = str(Path(args.execution_catalog).expanduser().resolve()) + if args.review_target is None: + args.review_target = args.planner_target + return args + + +def prepare_existing(args: argparse.Namespace) -> int: + apply_defaults(args) + if not args.epics: + raise PreparationError("--epics is required with --existing-workspace") + if args.skip_agent_probe and not ( + args.dry_run or os.environ.get("AGENT_OPS_TESTING") == "1" + ): + raise PreparationError( + "--skip-agent-probe is test-only; use AGENT_OPS_TESTING=1" + ) + workspace = resolve_repo(args.workspace or ".") + if args.repo is not None and resolve_repo(args.repo) != workspace: + raise PreparationError("--repo must match --workspace in existing-workspace mode") + milestone_path, milestone_match = resolve_milestone(workspace, args.milestone) + milestone = existing_milestone_contract(milestone_path) + milestone_slug = milestone_match.group("slug") + phase_slug = milestone_match.group("phase") + selected = select_epics(parse_epics(milestone_path.read_text(encoding="utf-8")), args.epics) + batch_ids = [task_id for epic in selected for task_id in epic.task_ids] + identity = { + "milestone": str(milestone_path), + "workspace": str(workspace), + "selected_epics": [epic.epic_id for epic in selected], + "batch_task_ids": batch_ids, + } + develop = git(workspace, "config", "--get", "gitflow.branch.develop") + feature_prefix = git(workspace, "config", "--get", "gitflow.prefix.feature") + branch = git(workspace, "branch", "--show-current") + expected_branch = f"{feature_prefix}{milestone_slug}" if feature_prefix else "" + if not develop or not feature_prefix or branch != expected_branch: + raise PreparationError( + f"current workspace must use the target Milestone feature branch: " + f"expected={expected_branch or 'missing-gitflow-config'} actual={branch or 'detached'}" + ) + current_path = workspace / "agent-roadmap" / "current.md" + expected_current_target = f"phase/{phase_slug}/milestones/{milestone_slug}.md" + if ( + not current_path.is_file() + or expected_current_target not in current_path.read_text(encoding="utf-8") + ): + raise PreparationError( + f"workspace-local current does not select target Milestone: {current_path}" + ) + common = git_common_dir(workspace) + state_root = common / "milestone-work-preparation" / milestone_slug + state_root.mkdir(parents=True, exist_ok=True) + state_path = state_root / "workspace-state.json" + + with (state_root / "workspace.lock").open("a+", encoding="utf-8") as lock: + try: + fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as exc: + raise PreparationError( + f"workspace preparation already running: {state_root / 'workspace.lock'}" + ) from exc + batch_state = read_json(state_root / "batch-state.json") + batch_matches = bool( + batch_state + and all(batch_state.get(key) == expected for key, expected in identity.items()) + ) + resuming_batch = bool( + batch_matches and batch_state and batch_state.get("status") != "completed" + ) + if batch_state and batch_state.get("status") != "completed" and not batch_matches: + raise PreparationError("another active Epic batch owns the current Milestone workspace") + if not resuming_batch: + ensure_clean(workspace, "feature workspace") + upstream = git( + workspace, + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{u}", + ) + if not upstream.endswith(f"/{branch}"): + raise PreparationError( + f"feature branch upstream mismatch: branch={branch} upstream={upstream}" + ) + if not resuming_batch and not args.dry_run: + remote_name = upstream.split("/", 1)[0] + git(workspace, "fetch", remote_name, branch) + head = git(workspace, "rev-parse", "HEAD") + upstream_head = git(workspace, "rev-parse", "@{u}") + if head != upstream_head: + if not resuming_batch or run( + ["git", "merge-base", "--is-ancestor", "@{u}", "HEAD"], + cwd=workspace, + check=False, + ).returncode != 0: + raise PreparationError("current feature branch is not synchronized with its upstream") + emit( + "PREFLIGHT_READY", + branch=branch, + milestone=str(milestone_path), + workspace=str(workspace), + existing_workspace=True, + ) + if args.dry_run: + return 0 + if selected and not args.skip_agent_probe and not resuming_batch: + probe_targets( + workspace, + args.execution_catalog, + args.planner_target, + args.review_target, + ) + ensure_clean(workspace, "feature workspace after agent probe") + state = { + "status": "workspace-ready", + "milestone": str(milestone_path), + "milestone_slug": milestone_slug, + "branch": branch, + "workspace": str(workspace), + "execution_catalog": args.execution_catalog, + "planner_target": args.planner_target, + "review_target": args.review_target, + "existing_workspace": True, + } + atomic_json(state_path, state) + emit("WORKSPACE_READY", **state) + return coordinate_batch( + args=args, + workspace=workspace, + milestone=milestone_path, + milestone_slug=milestone_slug, + phase_slug=phase_slug, + common=common, + ) + + +def prepare(args: argparse.Namespace) -> int: + apply_defaults(args) + if args.repo is None or args.workspace is None: + raise PreparationError("--repo and --workspace are required unless --existing-workspace is used") + if args.skip_agent_probe and not ( + args.dry_run or os.environ.get("AGENT_OPS_TESTING") == "1" + ): + raise PreparationError( + "--skip-agent-probe is test-only; use --dry-run or AGENT_OPS_TESTING=1" + ) + repo = resolve_repo(args.repo) + milestone_path, milestone_match = resolve_milestone(repo, args.milestone) + milestone = milestone_contract(milestone_path) + phase_slug = milestone_match.group("phase") + milestone_slug = milestone_match.group("slug") + phase = phase_contract(repo, phase_slug) + workspace = resolve_workspace(repo, args.workspace) + if workspace == repo: + raise PreparationError("feature workspace must differ from the develop checkout") + try: + workspace.relative_to(repo) + except ValueError: + pass + else: + raise PreparationError("feature workspace must not be nested inside the develop checkout") + common = git_common_dir(repo) + state_root = common / "milestone-work-preparation" / milestone_slug + state_path = state_root / "workspace-state.json" + batch_state_path = state_root / "batch-state.json" + target_milestone_path = workspace / milestone_path.relative_to(repo) + state_root.mkdir(parents=True, exist_ok=True) + lock_path = state_root / "workspace.lock" + with lock_path.open("a+", encoding="utf-8") as lock: + try: + fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as exc: + raise PreparationError(f"workspace preparation already running: {lock_path}") from exc + + develop = git(repo, "config", "--get", "gitflow.branch.develop") + feature_prefix = git(repo, "config", "--get", "gitflow.prefix.feature") + if not develop or not feature_prefix: + raise PreparationError("gitflow.branch.develop and gitflow.prefix.feature are required") + current_branch = git(repo, "branch", "--show-current") + if current_branch != develop: + raise PreparationError(f"prepare from Git Flow develop branch: expected={develop} actual={current_branch}") + ensure_clean(repo, "develop checkout") + if not ref_exists(repo, f"refs/remotes/{args.remote}/{develop}") and args.dry_run: + raise PreparationError(f"remote develop ref missing: {args.remote}/{develop}") + if not args.dry_run: + git(repo, "fetch", args.remote, develop) + remote_develop = f"refs/remotes/{args.remote}/{develop}" + if git(repo, "rev-parse", "HEAD") != git(repo, "rev-parse", remote_develop): + raise PreparationError(f"develop checkout is not exactly synchronized with {args.remote}/{develop}") + if run( + ["git", "check-ignore", "--quiet", "agent-roadmap/current.md"], + cwd=repo, + check=False, + ).returncode != 0: + raise PreparationError( + "agent-roadmap/current.md must be ignored for workspace-local Milestone selection" + ) + + if not args.skip_agent_probe and not args.dry_run: + probe_targets( + repo, + args.execution_catalog, + args.planner_target, + args.review_target, + ) + ensure_clean(repo, "develop checkout after agent probe") + + branch = f"{feature_prefix}{milestone_slug}" + local_ref = f"refs/heads/{branch}" + remote_ref = f"refs/remotes/{args.remote}/{branch}" + branch_worktrees = [ + item + for item in worktrees(repo) + if item.get("branch") == local_ref + ] + if branch_worktrees and Path(branch_worktrees[0]["worktree"]).resolve() != workspace: + raise PreparationError( + f"feature branch already belongs to another worktree: {branch_worktrees[0]['worktree']}" + ) + + emit( + "PREFLIGHT_READY", + branch=branch, + milestone=str(milestone_path), + workspace=str(workspace), + ) + if args.dry_run: + return 0 + + local_exists = ref_exists(repo, local_ref) + remote_exists = ref_exists(repo, remote_ref) + prior_batch = read_json(batch_state_path) if args.epics else None + resuming_batch = bool( + prior_batch + and prior_batch.get("status") != "completed" + and prior_batch.get("workspace") == str(workspace) + and prior_batch.get("milestone") == str(target_milestone_path) + ) + if not local_exists: + if remote_exists: + git(repo, "branch", "--track", branch, f"{args.remote}/{branch}") + else: + git(repo, "branch", branch, remote_develop) + if run( + ["git", "merge-base", "--is-ancestor", remote_develop, local_ref], + cwd=repo, + check=False, + ).returncode != 0: + raise PreparationError(f"feature branch does not contain current {args.remote}/{develop}") + if remote_exists and git(repo, "rev-parse", local_ref) != git(repo, "rev-parse", remote_ref): + if not resuming_batch or run( + ["git", "merge-base", "--is-ancestor", remote_ref, local_ref], + cwd=repo, + check=False, + ).returncode != 0: + raise PreparationError("existing local and remote feature branches differ") + if not remote_exists: + git(repo, "push", "--set-upstream", args.remote, branch) + else: + git(repo, "branch", "--set-upstream-to", f"{args.remote}/{branch}", branch) + emit("FEATURE_BRANCH_PUSHED", branch=branch, remote=args.remote) + + if branch_worktrees: + if not resuming_batch: + ensure_clean(workspace, "feature workspace") + else: + if workspace.exists() and any(workspace.iterdir()): + raise PreparationError(f"workspace exists and is not empty: {workspace}") + workspace.parent.mkdir(parents=True, exist_ok=True) + git(repo, "worktree", "add", str(workspace), branch) + ensure_clean(workspace, "feature workspace") + + current_path = workspace / "agent-roadmap" / "current.md" + current_path.parent.mkdir(parents=True, exist_ok=True) + current_path.write_text( + render_current( + phase_slug=phase_slug, + phase=phase, + milestone_slug=milestone_slug, + milestone=milestone, + ), + encoding="utf-8", + ) + if not resuming_batch: + ensure_clean(workspace, "feature workspace") + state = { + "status": "workspace-ready", + "milestone": str(target_milestone_path), + "milestone_slug": milestone_slug, + "branch": branch, + "workspace": str(workspace), + "execution_catalog": args.execution_catalog, + "planner_target": args.planner_target, + "review_target": args.review_target, + } + atomic_json(state_path, state) + emit("WORKSPACE_READY", **state) + if args.epics: + return coordinate_batch( + args=args, + workspace=workspace, + milestone=target_milestone_path, + milestone_slug=milestone_slug, + phase_slug=phase_slug, + common=common, + ) + return 0 + + +def main(argv: Iterable[str] | None = None) -> int: + args = parser().parse_args(argv) + try: + apply_defaults(args) + return prepare_existing(args) if args.existing_workspace else prepare(args) + except (OSError, PreparationError) as exc: + emit("FAILED", reason=str(exc)) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/agent-ops/skills/common/prepare-milestone-workspace/tests/test_prepare_workspace.py b/agent-ops/skills/common/prepare-milestone-workspace/tests/test_prepare_workspace.py new file mode 100644 index 0000000..1a799f6 --- /dev/null +++ b/agent-ops/skills/common/prepare-milestone-workspace/tests/test_prepare_workspace.py @@ -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() diff --git a/agent-ops/skills/common/refine-local-plans/SKILL.md b/agent-ops/skills/common/refine-plans/SKILL.md similarity index 70% rename from agent-ops/skills/common/refine-local-plans/SKILL.md rename to agent-ops/skills/common/refine-plans/SKILL.md index 34169c4..14bb40e 100644 --- a/agent-ops/skills/common/refine-local-plans/SKILL.md +++ b/agent-ops/skills/common/refine-plans/SKILL.md @@ -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 밖 복구 파일을 만들지 않는다. diff --git a/agent-ops/skills/common/roadmap-sdd/SKILL.md b/agent-ops/skills/common/roadmap-sdd/SKILL.md index e3fb369..58334fb 100644 --- a/agent-ops/skills/common/roadmap-sdd/SKILL.md +++ b/agent-ops/skills/common/roadmap-sdd/SKILL.md @@ -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 diff --git a/agent-ops/skills/common/router.md b/agent-ops/skills/common/router.md index eae86db..923ef53 100644 --- a/agent-ops/skills/common/router.md +++ b/agent-ops/skills/common/router.md @@ -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` 순서를 유지한다. diff --git a/agent-ops/skills/common/sync-agent-ui/SKILL.md b/agent-ops/skills/common/sync-agent-ui/SKILL.md index 7c5ce3f..d3fdfda 100644 --- a/agent-ops/skills/common/sync-agent-ui/SKILL.md +++ b/agent-ops/skills/common/sync-agent-ui/SKILL.md @@ -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를 다시 확인한다. diff --git a/agent-ops/skills/common/sync-milestone-workstate/SKILL.md b/agent-ops/skills/common/sync-milestone-workstate/SKILL.md index 8e22b12..5f828e9 100644 --- a/agent-ops/skills/common/sync-milestone-workstate/SKILL.md +++ b/agent-ops/skills/common/sync-milestone-workstate/SKILL.md @@ -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를 바꾸지 않는다. diff --git a/agent-ops/skills/common/update-roadmap/SKILL.md b/agent-ops/skills/common/update-roadmap/SKILL.md index 0d4b173..5147b22 100644 --- a/agent-ops/skills/common/update-roadmap/SKILL.md +++ b/agent-ops/skills/common/update-roadmap/SKILL.md @@ -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를 무시하지 않는다.