feat(agent-ops): UI 작업 매핑 정합화를 확장한다

계획 재구성과 Milestone 종료에서도 UI 상태 변경의 소유자와 완료 근거를 일관되게 유지한다.
This commit is contained in:
toki 2026-07-29 09:23:35 +09:00
parent 18c9f08c07
commit a52c73bc2a
13 changed files with 1508 additions and 92 deletions

View file

@ -12,7 +12,7 @@
```text
agent-ui/
README.md
.sync-state.json # 선택: agent-ui -> code 동기화 기준점과 pending code-work 매핑
.sync-state.json # 선택: agent-ui -> code 기준점과 plan/Milestone work 매핑
USER_REVIEW.md # 선택: 사용자 판단이 필요한 활성 리뷰
archive/
user-review/
@ -93,9 +93,11 @@ agent-ui/
- `update-agent-ui` 또는 수동 `validate-agent-ui`가 코드 반영 필요성을 판단했으면 그 판단을 다음 단계로 전달한다. 전달된 판단이 있으면 뒤 단계는 문서 정합성과 sync intent 자체를 재판단하지 않지만, `sync-agent-ui`는 실제 코드 반영 방식 판정을 수행한다.
- 직접 sync가 검증되면 반영된 view/component의 `status``구현됨`으로 바꾼다.
- `sync-agent-ui`가 코드 작업을 `plan-required`로 라우팅한 경우에는 일반 plan/review 문서에 agent-ui 전용 completion section을 추가하지 않는다.
- plan pair 생성 뒤 `sync-agent-ui mode=prepare-code-work``.sync-state.json.pending_code_work`에 task path, agent-ui 문서, 코드 후보, 검증 기준을 기록한다.
- 일반 code-review PASS와 exact `complete.log` 생성 뒤 원래 `task-path``completion-log`를 전달받은 `sync-agent-ui mode=reconcile-completion`이 matching pending entry만 사용해 `update-agent-ui``validate-agent-ui`를 순서대로 실행하고 해당 view/component/frame을 `구현됨`으로 반영한다.
- `sync-agent-ui`가 코드 작업을 `milestone-required`로 라우팅해 `agent-ui 상태 반영: 대기`를 남긴 Milestone만 종료 검토 통과 시 `구현됨` 반영 gate가 된다. 최종 검증과 code evidence가 확인된 view/component/frame만 반영하며, Milestone 완료만으로 agent-ui 전체 status를 일괄 변경하지 않는다.
- plan pair 생성 뒤 `sync-agent-ui mode=prepare-code-work``.sync-state.json.pending_code_work`에 task path, status 전환 대상 view/component, validation-only frame-view, 코드 후보, 검증 기준을 기록한다.
- pending UI task가 plan refinement 또는 sibling reindex로 경로가 바뀌면 전체 status 대상을 닫는 child 하나로 기존 entry를 rebind한다. 이전 task path를 남기거나 같은 status/frame path를 여러 child에 중복 배정하지 않는다.
- 일반 code-review PASS와 exact `complete.log` 생성 뒤 원래 `task-path``completion-log`를 전달받은 `sync-agent-ui mode=reconcile-completion`이 matching pending entry만 사용해 `update-agent-ui``validate-agent-ui`를 순서대로 실행한다. view/component만 `구현됨`으로 반영하고 frame-view에는 status를 추가하지 않는다.
- `sync-agent-ui`가 코드 작업을 `milestone-required`로 라우팅하면 exact active Milestone과 UI/code evidence를 `.sync-state.json.pending_milestone_work`에 기록한다. 일반 Milestone 문서에는 agent-ui 전용 완료 필드를 추가하지 않는다.
- Milestone 종료 요청에서는 `complete-milestone check-only`가 종료 가능 근거를 반환한 뒤에만 `sync-agent-ui reconcile-milestone-completion`을 실행한다. 최종 검증과 code evidence가 확인된 view/component만 status를 반영하고 연결된 frame은 정합성만 검증하며, 성공 뒤에만 caller/router가 Milestone close를 계속한다.
- 사용자 확인 또는 후속 검증에서 불일치가 발견되면 해당 항목은 `계획`으로 되돌릴 수 있다.
- `sync-agent-ui`가 검증 실패, 환경 차단, 구현 방향 충돌, 반복 실패를 스스로 해결하지 못하면 commit/push하지 않고 `agent-ui/USER_REVIEW.md`에 게이트를 남긴다.
- 실패한 `sync-agent-ui`가 남긴 작업트리 변경은 사용자 결정과 충돌하지 않으면 보존하고, USER_REVIEW 해결 후 같은 변경을 이어서 재검증할 수 있다.
@ -107,8 +109,12 @@ agent-ui/
- 기존 방식으로 생성되어 `.sync-state.json`이 없는 agent-ui는 legacy 상태다. 이 경우 기본 변경분 sync를 시작하지 말고 validate 후 baseline migration으로 기준점을 먼저 만든다.
- baseline은 `code-first`, `concept-first`, `blank` 모두에서 생성된 agent-ui 기준선을 뜻한다. `concept-first` baseline은 코드 구현 완료가 아니라 agent-ui 기준선 확정이다.
- `last_synced_head`는 agent-ui 변경분과 코드 반영이 들어간 sync 결과 commit hash다. `.sync-state.json` 자체를 기록한 commit hash가 아니다.
- schema version 2의 `pending_code_work``plan-required` 코드 작업의 task/UI 매핑이다. 일반 plan/review/complete.log는 이 매핑 schema를 알 필요가 없다.
- pending entry는 `prepare-code-work`만 생성/갱신하고 `reconcile-completion` 검증 통과 시에만 제거한다. 실패, ambiguous completion, evidence 부족에서는 유지한다.
- schema version 4의 `pending_code_work`/`reconciled_code_work`는 `plan-required` task/UI 매핑과 완료 근거고, `pending_milestone_work`/`reconciled_milestone_work`는 `milestone-required` Milestone/UI 매핑과 완료 근거다. 일반 plan/review/complete.log/Milestone 문서는 이 schema를 알 필요가 없다.
- `status_paths`에는 status schema가 있는 view/component만, `frame_paths`에는 validation-only frame-view만 기록한다.
- pending entry의 `status_paths``frame_paths`는 task와 Milestone을 통틀어 중복 소유할 수 없다. refinement/reindex에서는 helper의 `previous-task-path` rebind를 사용하고 scope/evidence 변경이 있을 때만 검증 후 replace한다.
- pending entry는 `prepare-code-work`만 생성/갱신하고 `reconcile-completion` 검증 통과 시에만 `reconciled_code_work`로 이동한다. 실패, ambiguous completion, evidence 부족에서는 유지한다.
- Milestone pending entry는 `prepare-milestone-work`만 생성/갱신하고 `complete-milestone check-only`와 UI 검증이 모두 통과한 `reconcile-milestone-completion`에서만 `reconciled_milestone_work`로 이동한다. 실패하면 Milestone close를 진행하지 않는다.
- `sync-agent-ui`의 prepare, reconcile, direct/baseline 기록은 모두 `sync-agent-ui/scripts/sync_state.py`의 agent-ui directory lock과 inspection 시점 SHA-256 검증으로 수행한다. shared state를 수동 read-modify-write하지 않는다.
- `reconcile-completion`은 matching agent-ui/code 결과를 모두 포함하는 commit hash가 검증된 경우에만 `last_synced_head`를 갱신한다. 그렇지 않으면 기존 기준점을 보존하고 notes에 사유를 남긴다.
- baseline 또는 sync 완료 시 commit은 두 단계로 남길 수 있다. 먼저 agent-ui/code 변경 commit을 만들고, 그 commit hash를 `.sync-state.json`에 기록한 별도 commit을 만든 뒤 push한다.
- 변경분 판정에서는 `.sync-state.json` 자체 변경을 제외한다.

View file

@ -51,6 +51,8 @@ source_evidence:
- `구현됨`, `계획`, `가정`, `불명확` 상태를 섞어 쓰지 않는다.
- 기본 코드 동기화는 `.sync-state.json` 기준 이후 변경분만 대상으로 한다.
- plan-required 코드 작업의 task/UI 매핑과 완료 정합화는 `.sync-state.json.pending_code_work`를 사용한다.
- milestone-required 코드 작업의 Milestone/UI 매핑과 완료 정합화는 `.sync-state.json.pending_milestone_work`를 사용한다.
- view/component status와 validation-only frame-view를 분리하고 완료 기록은 대응하는 `reconciled_*_work`에 둔다.
- 전체 동기화는 사용자가 명시한 경우에만 수행한다.
## Decision History

View file

@ -16,7 +16,7 @@ surface_type: ops-dev
- `definition/`: 현재 UI 정의 source of truth
- `frame/`: visual source가 있는 view의 wireframe 연결
- `.sync-state.json`: agent-ui -> code 동기화 기준점과 plan-required pending task 매핑
- `.sync-state.json`: agent-ui -> code 동기화 기준점, plan/Milestone pending 매핑, 완료 정합화 기록
- `USER_REVIEW.md`: 사용자 판단이 필요한 활성 질문
- `archive/user-review/`: 해결된 사용자 리뷰 로그
@ -27,4 +27,6 @@ surface_type: ops-dev
- `.excalidraw` 파일만으로 UI 기준을 확정하지 않는다.
- 초기 생성 이후 기본 흐름은 `agent-ui -> code` 방향이다.
- plan-required 코드 작업은 `.sync-state.json.pending_code_work`로 연결하고 일반 code-review 완료 뒤 `sync-agent-ui`가 정합화한다.
- milestone-required 코드 작업은 `.sync-state.json.pending_milestone_work`로 연결하고 Milestone 종료 직전에 `sync-agent-ui`가 정합화한다.
- `sync-agent-ui`의 state transition은 bundled helper가 담당하며 frame-view는 status 전환 대상이 아니다.
- `definition/archive/**``archive/user-review/**`는 과거 기록이며 일반 작업에서 읽지 않는다.

View file

@ -1,5 +1,5 @@
{
"schema_version": 2,
"schema_version": 4,
"surface_type": "ops-dev",
"baseline_mode": "<code-first|concept-first|blank|migration>",
"last_sync_mode": "baseline",
@ -8,5 +8,8 @@
"agent_ui_paths": [],
"code_paths": [],
"pending_code_work": [],
"reconciled_code_work": [],
"pending_milestone_work": [],
"reconciled_milestone_work": [],
"notes": []
}

View file

@ -77,15 +77,8 @@ Task 체크리스트는 Epic 바로 아래의 flat list로 유지하고, 구현
- 요청일: <YYYY-MM-DD | 없음>
- 완료 근거: <모든 기능 Task와 Task 안에 명시된 검증 충족 구현 잠금 해제 여부를 1~3줄로 요약>
- 검토 항목: <없음 | 에이전트/런타임이 확인할 완료 근거 또는 archive 조건>
- agent-ui 상태 반영: <해당 없음 | 대기 | 완료 | 차단: 사유>
- 리뷰 코멘트: <없음 | 보완/보류/폐기 방향성>
<!--
기본값은 `해당 없음`이다.
`sync-agent-ui`가 코드 작업을 `milestone-required`로 라우팅했거나 사용자가 Milestone 종료 검토에서 agent-ui 구현 상태 반영을 명시한 경우에만 `대기`로 둔다.
`대기`인 경우 Milestone 종료 검토에서 최종 검증과 code evidence가 확인된 agent-ui view/component/frame만 `구현됨`으로 반영한다.
-->
## 범위 제외
- < Milestone에서 의도적으로 하지 않는 >

View file

@ -1,6 +1,5 @@
---
name: complete-milestone
version: 1.1.0
description: "마일스톤 완료해도 될지 검토, 현 마일스톤 종료 검토, 현재 마일스톤 닫고 다음 마일스톤 지정, 검토중 Milestone 코드레벨 종료 감사 요청에 사용한다. 코드/테스트/계약/evidence를 점검하고 작은 보완은 처리하거나 큰 보완은 plan으로 넘기며, agent-spec이 있으면 update-spec을 필수 gate로 수행한 뒤 update-roadmap으로 완료/archive와 다음 Milestone 지정을 처리한다."
---
@ -24,6 +23,7 @@ description: "마일스톤 완료해도 될지 검토, 현 마일스톤 종료
- `next-milestone`: 종료 후 `current.md`에 둘 다음 Milestone 후보. 없으면 Phase 흐름에서 자동 후보를 찾되 모호하면 보고한다. (선택)
- `mode`: `check-only` 또는 `close`. 기본값은 `close`다. (선택)
- `evidence`: 완료 판단에 사용할 complete.log, 테스트 결과, 사용자 설명, PR/커밋 등. (선택)
- `verification-context`: 환경, 출처, 명령, 기대 결과, precondition, 제약, gap, confidence를 담은 중립 검증 handoff. 있으면 사용하고 현재 checkout과 다시 대조한다. (선택)
## 먼저 확인할 것
@ -52,7 +52,8 @@ description: "마일스톤 완료해도 될지 검토, 현 마일스톤 종료
- Milestone `기능` Task와 검증 문구, `완료 리뷰`, complete.log, SDD Evidence Map, 관련 코드/계약/테스트를 비교한다.
- `agent-task/archive/**`는 같은 `m-<milestone-slug>` complete.log 후보처럼 규칙상 허용된 범위만 좁게 읽는다.
- 코드/계약 변경에 닿으면 관련 domain rule과 `agent-contract/index.md` 라우팅을 따른다.
- 필요한 검증 명령은 `agent-test/<env>/rules.md`와 관련 profile을 따른다. 환경 미지정은 local로 본다.
- `verification-context`가 있으면 출처 경로, 명령, 기대 결과, precondition, 제약, gap, confidence가 현재 checkout에 맞는지 확인한다.
- handoff가 없거나 불완전하면 repository manifest, script, workflow, domain rule, 관련 테스트와 안전한 read-only probe에서 부족한 검증 사실을 보완한다. 선택 handoff 누락만으로 종료 검토를 차단하지 않는다.
4. **보완 분기**
- 작은 문서/코드/테스트 보완으로 바로 해결 가능한 이슈는 직접 수정하고 검증한다.
@ -92,6 +93,7 @@ description: "마일스톤 완료해도 될지 검토, 현 마일스톤 종료
- [ ] 대상 Milestone이 활성 경로에서 정확히 하나로 확정되었는가
- [ ] Milestone 상태, 기능 Task, 구현 잠금, 완료 리뷰, SDD gate를 확인했는가
- [ ] 코드/계약/테스트 evidence를 확인했거나 범위 불명확 사유를 보고했는가
- [ ] 전달된 verification context와 repository-native fallback의 출처·gap·confidence를 구분했는가
- [ ] 작은 보완은 검증까지 수행하고, 큰 보완은 plan으로 넘겼는가
- [ ] `agent-spec/`가 있으면 `update-spec` 결과를 확인하고 완료 진행 가능 상태가 `Spec updated` 또는 `Spec update not needed`인지 판단했는가
- [ ] `Spec blocked` 또는 `create-spec needed` 상태에서 Milestone 완료/archive를 하지 않았는가

View file

@ -1,7 +1,6 @@
---
name: create-roadmap
version: 1.19.2
description: AI-first 개인/소규모 프로젝트의 전체 목표, Phase scaffold, Phase 하위 Milestone 문서, 전역 priority-queue.md 실행 순서 문서, 로컬 current.md 활성 Phase/Milestone 창, archive Phase scaffold를 처음 생성하고, agent-ui 코드 동기화 Milestone의 완료 리뷰 상태 반영 항목을 포함하는 공통 스킬
description: AI-first 개인/소규모 프로젝트의 전체 목표, Phase scaffold, Phase 하위 Milestone 문서, 전역 priority-queue.md 실행 순서 문서, 로컬 current.md 활성 Phase/Milestone 창, archive Phase scaffold를 처음 생성할 때 사용한다.
---
# 로드맵 생성
@ -127,9 +126,6 @@ agent-roadmap/
- epic-id와 item-id는 공백 없는 짧은 ASCII 토큰이며, 해당 Milestone 안에서만 유일하면 된다.
- 검증이 필요한 Task에만 같은 항목 안에 `검증: <명령/확인 방법/기대 결과>`를 붙인다. 검증이 필요 없는 기능에는 억지 검증을 붙이지 않는다.
- `완료 리뷰`는 새 Milestone에서는 `상태: 없음`, `요청일: 없음`으로 두고, 모든 기능 Task와 Task 안에 명시된 검증이 충족되고 `구현 잠금`이 해제된 뒤 에이전트/런타임 완료 근거를 기록할 때 갱신한다. 에이전트가 확정할 수 없는 결정 항목은 완료 리뷰가 아니라 `구현 잠금 > 결정 필요` 또는 SDD `USER_REVIEW.md`로 분리한다.
- `agent-ui 상태 반영` 항목은 모든 신규 Milestone의 `완료 리뷰`에 두고 기본값은 `해당 없음`으로 적는다.
- `sync-agent-ui``milestone-required` 코드 작업 라우팅으로 생성/갱신하는 Milestone, 또는 사용자가 Milestone 종료 검토에서 agent-ui 구현 상태 반영을 명시한 Milestone만 `agent-ui 상태 반영: 대기`로 둔다.
- `대기` Milestone은 종료 검토에서 최종 검증과 code evidence가 확인된 agent-ui view/component/frame만 `구현됨`으로 반영한다.
- `범위`, `범위 제외`, `작업 컨텍스트`는 설명 목록으로 작성하고, 실행해야 할 작업을 이 섹션에 숨기지 않는다.
## 먼저 확인할 것
@ -191,7 +187,6 @@ agent-roadmap/
- `check-gate` 결과는 `SDD gate` 항목에 `pass`, `review-required`, `blocked`, `invalid` 중 하나로 분리해 남긴다. `review-ready`로 만든 `USER_REVIEW.md` 때문에 막힌 경우는 `review-required`로 보고하고 형식 보완 실패로 보지 않는다.
- `[검토중]` Milestone이 있다면 `구현 잠금`이 해제되어 있고 미완료 `결정 필요` 항목이 없는지 확인한다.
- Milestone 문서에 `완료 리뷰` 섹션이 있는지 확인한다.
- `sync-agent-ui``milestone-required` 코드 작업 라우팅으로 생성/갱신한 Milestone이면 `완료 리뷰``agent-ui 상태 반영: 대기`가 있고, 그 외 Milestone은 `agent-ui 상태 반영: 해당 없음`인지 확인한다.
## 출력 형식

View file

@ -5,13 +5,17 @@
- SDD/spec gate 자체의 작성, 갱신, gate 확인, 사용자 리뷰, 잠금 해제는 `roadmap-sdd`로 보낸다.
- 로드맵/마일스톤 생성 또는 갱신 요청 안에 SDD 필요 여부와 gate 연결이 포함되면 `create-roadmap` 또는 `update-roadmap`을 진입점으로 삼고, 해당 흐름에서 `roadmap-sdd` create/check를 처리한다.
- agent-spec은 구현 후 현재 상태를 설명하는 living spec이다. SDD/spec gate와 구분하며, 현재 구현 스펙 생성은 `create-spec`, 갱신은 `update-spec`으로 보낸다.
- "마일스톤 완료해도 될지 검토", "현 마일스톤 종료 검토", "현재 마일스톤 닫고 다음 마일스톤 지정"처럼 종료 판단, spec sync, roadmap archive, 다음 Milestone 지정을 함께 요구하는 요청은 `complete-milestone`으로 보낸다. 이 흐름 안에서 `update-spec`을 필수 gate로 수행한다.
- "마일스톤 완료해도 될지 검토", "현 마일스톤 종료 검토", "현재 마일스톤 닫고 다음 마일스톤 지정"처럼 종료 판단, 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`으로 처리한다.
- active/archive `complete.log`, 관련 파일, git history를 종합해 Milestone 작업 상태를 복구하거나 확인하는 요청은 `sync-milestone-workstate`로 처리한다.
- 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`를 사용한다.
- `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하지 않는다.
| 요청 키워드 | SKILL.md |
|------------|----------|

View file

@ -1,7 +1,6 @@
---
name: sync-agent-ui
version: 1.2.0
description: 정합화된 agent-ui 변경분을 코드에 직접 반영하거나 plan/roadmap으로 라우팅하고, plan-required 작업 매핑과 완료 후 agent-ui 상태 정합화까지 소유하는 스킬. "agent-ui와 코드 동기화", "화면정의서대로 코드 반영", agent-ui 코드 작업 완료 정합화 요청 시 사용한다.
description: 정합화된 agent-ui 변경분을 코드에 직접 반영하거나 plan/roadmap으로 라우팅하고 plan·Milestone 작업 매핑과 완료 후 상태 정합화를 소유한다. agent-ui와 코드 동기화, 화면정의서대로 코드 반영, agent-ui 코드 작업 준비·완료 정합화 요청에 사용한다.
---
# sync-agent-ui
@ -12,7 +11,7 @@ description: 정합화된 agent-ui 변경분을 코드에 직접 반영하거나
기본 동작은 `.sync-state.json``last_synced_head` 이후 agent-ui 변경분만 반영하며, 사용자가 전체 동기화를 명시한 경우에만 현재 agent-ui 전체를 코드와 대조한다.
코드 반영 전에 코드 작업 규모를 판정해 작은 작업은 직접 반영하고, 중간 작업은 `plan` 루프로, Milestone이 필요한 큰 코드 작업은 `update-roadmap`의 Milestone/Epic/Task 갱신으로 넘긴다.
`plan-required` 작업은 일반 plan/code-review 문서에 agent-ui 전용 section을 넣지 않는다. 대신 plan pair 생성 직후 `prepare-code-work`로 task와 agent-ui 문서를 매핑하고, 일반 code-review PASS와 `complete.log` 생성 뒤 `reconcile-completion`으로 status/code evidence를 정합화한다.
`milestone-required`로 라우팅한 경우에는 이 스킬 실행 안에서 코드 구현, status 전환, sync state 갱신, commit/push를 하지 않는다.
`milestone-required` 작업도 일반 Milestone 문서에 agent-ui 전용 완료 필드를 넣지 않는다. `update-roadmap`이 확정한 활성 Milestone을 `prepare-milestone-work`로 매핑하고, 종료 직전 `complete-milestone check-only` 근거를 받은 `reconcile-milestone-completion`이 status/code evidence를 정합화한다.
## 언제 호출할지
@ -21,34 +20,44 @@ description: 정합화된 agent-ui 변경분을 코드에 직접 반영하거나
- 사용자가 "agent-ui와 코드 전체 동기화"처럼 full sync를 명시할 때
- `plan-required`로 생성된 task와 agent-ui 문서의 연결을 기록할 때
- 일반 code-review PASS 뒤 생성된 `complete.log`를 agent-ui status/code evidence에 반영할 때
- `milestone-required`로 확정된 활성 Milestone과 agent-ui 문서를 연결할 때
- Milestone 종료 감사 통과 후보의 agent-ui 상태를 실제 종료 전에 정합화할 때
## 입력
- `mode`: `incremental`, `full`, `prepare-code-work`, `reconcile-completion` 중 하나. 기본값은 `incremental` (선택)
- `mode`: `incremental`, `full`, `prepare-code-work`, `reconcile-completion`, `prepare-milestone-work`, `reconcile-milestone-completion` 중 하나. 기본값은 `incremental` (선택)
- `scope`: `all`, `view:<view-id>`, `component:<component-id>` 중 하나. 기본값은 `all` (선택)
- `validated`: `true` 또는 `false`. 기본값은 `false` (선택)
- `sync-intent-source`: `manual`, `update-agent-ui`, `validate-agent-ui` 중 하나 (선택)
- `execution-route`: `auto`, `direct-sync`, `plan-required`, `milestone-required` 중 하나. 기본값은 `auto` (선택)
- `task-path`: `agent-task/` 기준 원래 active task 경로. `prepare-code-work``reconcile-completion`에서 필수
- `agent-ui-docs`: 구현 대상 활성 definition/frame/component 문서 목록. `prepare-code-work`에서 필수
- `previous-task-path`: plan refinement/reindex 전에 pending entry가 가리키던 active task 경로. `prepare-code-work` rebind에서만 선택
- `milestone-path`: `agent-roadmap/phase/*/milestones/*.md`의 정확한 활성 Milestone 경로. Milestone prepare/reconcile에서 필수
- `agent-ui-docs`: 완료 시 `구현됨`으로 전환할 활성 view/component 문서 목록. `prepare-code-work`에서 필수
- `frame-docs`: 함께 검증할 활성 frame-view 문서 목록. `prepare-code-work`에서 선택
- `code-paths`: 구현 후보 또는 완료 evidence 코드 경로 목록. `prepare-code-work`에서 필수
- `verification-requirements`: 완료 정합화 전에 확인할 명령/기대 결과 목록. `prepare-code-work`에서 선택
- `completion-log`: 일반 code-review가 만든 정확한 `complete.log` 경로. `reconcile-completion`에서 필수
- `closure-evidence`: 같은 Milestone에 대해 `complete-milestone mode=check-only`가 반환한 종료 가능 근거와 검증 요약. `reconcile-milestone-completion`에서 필수
- `validation-evidence`: `update-agent-ui` 후 실행한 `validate-agent-ui` PASS 근거. `reconcile-completion`에서 필수
- `sync-result-head`: agent-ui와 코드 결과를 모두 포함한다고 검증된 commit hash. `reconcile-completion`에서 선택
- `push`: `true` 또는 `false`. 기본값은 `true` (선택)
- `push`: `true` 또는 `false`. `incremental|full`에서만 사용하며 기본값은 `true` (선택)
## 먼저 확인할 것
- [ ] `agent-ops/rules/common/rules-agent-ui.md`를 읽는다.
- [ ] `agent-ui/``agent-ui/definition/index.md` 존재 여부를 확인한다.
- [ ] `mode=incremental|full|prepare-code-work`이고 `validated=true`가 아니면 먼저 `validate-agent-ui`를 실행한다.
- [ ] `mode=incremental|full|prepare-code-work|prepare-milestone-work`이고 `validated=true`가 아니면 먼저 `validate-agent-ui`를 실행한다.
- [ ] `agent-ui/USER_REVIEW.md`가 있으면 현재 sync 범위와 관련된 미해결 항목이 없는지 확인한다.
- [ ] `agent-ui/.sync-state.json`을 읽는다. 없고 `mode=incremental|prepare-code-work|reconcile-completion`이면 legacy baseline migration 필요로 보고 중단한다.
- [ ] `schema_version: 1`이고 `pending_code_work`가 없으면 빈 배열로 취급해 호환하고, 이 스킬이 state를 실제 갱신할 때만 `schema_version: 2`로 올린다.
- [ ] `agent-ui/.sync-state.json`을 읽는다. 없고 `mode=incremental|prepare-code-work|reconcile-completion|prepare-milestone-work|reconcile-milestone-completion`이면 legacy baseline migration 필요로 보고 중단한다.
- [ ] `schema_version: 1|2|3`이면 bundled state helper가 기존 필드와 pending 매핑을 보존·정규화하고 `schema_version: 4`로 올리게 한다.
- [ ] `.sync-state.json`을 갱신하는 모든 모드에서 `agent-ops/skills/common/sync-agent-ui/scripts/sync_state.py`를 사용한다. shared state를 수동 read-modify-write하지 않는다.
- [ ] `mode=incremental|full`이면 수정할 UI 코드 경로에 해당하는 project/domain rule을 먼저 읽는다.
- [ ] `mode=incremental|full`이고 코드 변경 검증이 필요하면 작업 환경의 `agent-test/<env>/rules.md`를 읽는다. 환경 미지정은 `local`로 본다.
- [ ] `mode=prepare-code-work`이면 exact active `PLAN-*-G??.md`와 matching `CODE_REVIEW-*-G??.md``task-path`에 존재하는지 확인한다.
- [ ] `mode=reconcile-completion`이면 원래 `task-path`, exact `completion-log`가 존재하고 matching pending entry가 정확히 하나인지 확인한다.
- [ ] `mode=prepare-milestone-work`이면 `update-roadmap`이 확정한 exact active `milestone-path`가 존재하는지 확인한다.
- [ ] `mode=reconcile-milestone-completion`이면 같은 `milestone-path`의 pending entry와 `complete-milestone check-only` closure evidence가 있는지 확인한다.
- [ ] `git status --short`와 현재 `HEAD`를 확인한다.
- [ ] unrelated dirty worktree가 있으면 sync commit 전에 범위를 보고하고, agent-ui와 관련 코드 파일만 stage한다.
- [ ] 이전 sync 실패로 남은 관련 작업트리 변경이 있으면 보존할지, 사용자 결정과 충돌하는지 확인한다.
@ -59,18 +68,24 @@ description: 정합화된 agent-ui 변경분을 코드에 직접 반영하거나
1. **task 매핑 검증**
- `task-path`의 active PLAN/review pair가 같은 task header를 갖고 아직 verdict가 없는지 확인한다.
- `agent-ui-docs`가 현재 활성 문서이고 status가 `계획`인지 확인한다. `가정`, `불명확`, 이미 archive 된 문서는 매핑하지 않는다.
- `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`로 전달한다.
2. **pending entry 기록**
- `agent-ui/.sync-state.json.pending_code_work`에 아래 객체를 `task_path` 기준으로 upsert한다.
- 같은 `task_path`의 내용이 다르면 덮어쓰지 말고 현재 plan 범위와 비교해 갱신 근거를 확인한다.
- `agent-ui/.sync-state.json`의 SHA-256을 계산한 뒤 bundled helper의 `prepare` 명령에 `--expected-sha256`으로 넘긴다. helper는 agent-ui 디렉터리 잠금을 잡은 다음 digest를 다시 확인한다.
- 같은 `task_path`와 같은 내용은 idempotent `already_prepared`로 허용한다.
- 같은 `task_path`의 내용이 다르면 helper가 중단한다. follow-up plan으로 범위가 실제 바뀐 것을 확인한 경우에만 `--replace`를 사용한다.
- `previous-task-path`를 전달하면 helper가 기존 pending entry를 새 `task-path`로 원자적으로 rebind한다. scope/status/frame/code/verification 매핑이 달라지면 전체 결과를 닫는 child인지 검증한 뒤에만 `--replace`를 함께 사용한다.
- 하나의 `status_path` 또는 `frame_path`를 둘 이상의 pending task가 소유하려 하면 helper가 중단한다.
```json
{
"task_path": "agent-task/<task-name>",
"scope": "<scope>",
"agent_ui_paths": ["agent-ui/definition/.../index.md"],
"status_paths": ["agent-ui/definition/views/.../index.md"],
"frame_paths": ["agent-ui/frame/views/.../index.md"],
"code_paths": ["<code path>"],
"verification_requirements": ["<command>: <expected result>"],
"prepared_at": "<ISO-8601>",
@ -78,15 +93,31 @@ description: 정합화된 agent-ui 변경분을 코드에 직접 반영하거나
}
```
```bash
python3 agent-ops/skills/common/sync-agent-ui/scripts/sync_state.py prepare \
--state agent-ui/.sync-state.json \
--expected-sha256 <sha256> \
--task-path agent-task/<task-name> \
--scope <scope> \
--status-path agent-ui/definition/views/<view-id>/index.md \
--frame-path agent-ui/frame/views/<view-id>/index.md \
--code-path <code-path> \
--verification-requirement '<command>: <expected result>' \
--prepared-at <ISO-8601>
```
- `--status-path``--code-path`는 필요한 만큼 반복하고, frame이 없으면 `--frame-path`를 생략한다.
- refinement/reindex rebind에서는 `--previous-task-path agent-task/<old-task-name>`을 추가한다.
3. **state 경계 유지**
- `schema_version``2`로 올리고 `pending_code_work` 외의 baseline 필드를 보존한다.
- helper가 `schema_version``4`로 올리고 code/Milestone pending·reconciled 기록과 baseline 필드를 보존하게 한다.
- 이 모드는 `last_synced_head`, agent-ui status, code evidence를 바꾸지 않고 commit/push하지 않는다.
- 일반 plan/review 문서에는 agent-ui 전용 completion section이나 review 전용 status 전환 지시를 추가하지 않는다.
### reconcile-completion 절차
1. **완료 이벤트 매칭**
- exact `completion-log`가 일반 code-review PASS 또는 user-review-resolved PASS의 terminal artifact인지 확인한다.
- exact `completion-log``agent-task/archive/**/complete.log`에 있고 header task가 원래 `task-path`와 일치하며 loop history에 terminal PASS/RESOLVED가 있는지 확인한다.
- 입력된 원래 `task-path``pending_code_work[].task_path`와 exact match한다. archive destination suffix나 `completion-log` 경로에서 원래 task path를 역추정하지 않는다.
- zero/multiple match이면 agent-ui 문서를 추측해 갱신하지 않는다.
@ -97,17 +128,78 @@ description: 정합화된 agent-ui 변경분을 코드에 직접 반영하거나
- evidence가 부족하면 entry를 `pending`으로 유지하고 일반 plan/review artifact를 수정하지 않는다.
3. **agent-ui 상태 반영**
- matching 문서마다 `update-agent-ui``status=구현됨`, 실제 code evidence, `post-validate=false`, `post-sync=false`로 실행한다.
- 모든 문서 갱신 뒤 `validate-agent-ui`를 matching scope, `sync-intent=skip`으로 실행한다.
- pending entry의 `status_paths`에 있는 view/component 문서마다 `update-agent-ui``status=구현됨`, 실제 code evidence, `post-validate=false`, `post-sync=false`로 실행한다.
- `frame_paths`에는 status를 쓰지 않는다. 대응 definition과 visual source 연결을 검증 대상으로만 사용한다.
- 모든 문서 갱신 뒤 `validate-agent-ui`를 matching scope, `sync-intent=skip`으로 실행하고 PASS 근거를 `validation-evidence`로 고정한다.
- 갱신이나 검증이 실패하면 pending entry를 제거하지 않고 `agent-ui/USER_REVIEW.md``Source Skill: sync-agent-ui`, `Stage: completion-reconcile`로 차단 근거를 남긴다.
4. **pending entry 종료**
- 갱신과 검증이 모두 통과한 경우에만 matching pending entry를 제거한다.
- `last_sync_mode``plan-reconcile`, `last_synced_at`, `agent_ui_paths`, `code_paths`, `notes`를 완료 근거로 갱신한다.
- 갱신과 검증이 모두 통과한 뒤 `.sync-state.json`의 최신 SHA-256을 다시 계산하고 bundled helper의 `reconcile` 명령을 실행한다.
- helper가 matching pending entry를 제거하고 `reconciled_code_work``task_path`, exact `completion_log`, scope, status/frame/code paths, validation evidence를 기록하게 한다.
- helper가 `last_sync_mode``plan-reconcile`, `last_synced_at`, `agent_ui_paths`, `code_paths`, `notes`를 완료 근거로 갱신하게 한다.
- `sync-result-head`가 실제 commit이고 matching agent-ui/code 결과를 모두 포함하는지 검증된 경우에만 `last_synced_head`를 그 값으로 갱신한다. 그렇지 않으면 기존 `last_synced_head`를 보존하고 notes에 기준점 미갱신 사유를 남긴다.
- 이미 같은 `completion-log`가 notes에 완료 근거로 기록되어 있고 pending entry가 없으면 idempotent `ALREADY_RECONCILED`로 반환한다.
- 이미 같은 `task_path`와 `completion-log``reconciled_code_work`에 있으면 helper의 idempotent `already_reconciled` 결과를 사용한다.
- 이 모드는 일반 plan/review/complete.log를 수정하거나 commit/push하지 않는다.
```bash
python3 agent-ops/skills/common/sync-agent-ui/scripts/sync_state.py reconcile \
--state agent-ui/.sync-state.json \
--expected-sha256 <sha256> \
--task-path agent-task/<task-name> \
--completion-log agent-task/archive/<YYYY>/<MM>/<task-path>/complete.log \
--validation-evidence '<validate-agent-ui PASS evidence>' \
--reconciled-at <ISO-8601>
```
- 검증된 결과 commit이 있을 때만 `--sync-result-head <commit>`을 추가한다.
### Milestone 매핑과 완료 정합화 절차
1. **Milestone 작업 준비**
- `execution-route=milestone-required`이면 코드 구현을 시작하지 않고 먼저 `update-roadmap`으로 활성 Milestone을 생성하거나 정확히 하나를 선택한다.
- 일반 Milestone 문서에는 agent-ui 전용 완료 필드나 status 전환 지시를 쓰지 않는다.
- `agent-ui-docs`, `frame-docs`, `code-paths`, `verification-requirements``prepare-code-work`와 같은 기준으로 검증한다.
- `.sync-state.json`의 최신 SHA-256과 exact active `milestone-path`를 bundled helper의 `prepare-milestone`에 전달한다.
- 같은 Milestone과 같은 매핑은 `already_prepared`로 허용하고, 실제 scope/evidence 변경을 확인한 경우에만 `--replace`를 사용한다.
```bash
python3 agent-ops/skills/common/sync-agent-ui/scripts/sync_state.py prepare-milestone \
--state agent-ui/.sync-state.json \
--expected-sha256 <sha256> \
--milestone-path agent-roadmap/phase/<phase>/milestones/<milestone>.md \
--scope <scope> \
--status-path agent-ui/definition/views/<view-id>/index.md \
--frame-path agent-ui/frame/views/<view-id>/index.md \
--code-path <code-path> \
--verification-requirement '<command>: <expected result>' \
--prepared-at <ISO-8601>
```
2. **종료 직전 정합화**
- caller/router는 Milestone 종료 요청에서 먼저 `complete-milestone mode=check-only`를 실행한다. 종료 가능 판정과 실제 검증 근거가 없으면 이 모드를 실행하지 않는다.
- `closure-evidence`에는 exact `milestone-path`, check-only 판정, 코드레벨 감사, 실행한 검증 결과, spec check-only 결과를 식별 가능하게 요약한다. 재시도 idempotency를 위해 실행 시각처럼 의미 없는 가변 값은 넣지 않는다.
- pending entry의 모든 `code_paths``verification_requirements`가 check-only 근거와 현재 checkout에서 충족되는지 다시 확인한다.
- `status_paths`의 view/component만 `update-agent-ui status=구현됨`과 실제 code evidence로 갱신하고, `frame_paths`는 연결 정합성만 검증한다.
- 모든 문서 갱신 뒤 `validate-agent-ui sync-intent=skip`을 실행한다. 실패하면 pending entry를 유지하고 Milestone close를 호출하지 않는다.
3. **Milestone pending 종료**
- 갱신과 검증이 통과한 뒤 최신 state SHA-256과 closure/validation evidence를 helper의 `reconcile-milestone`에 전달한다.
- helper가 matching entry를 `pending_milestone_work`에서 `reconciled_milestone_work`로 이동하고 완료 근거를 기록한다.
- 성공 또는 같은 evidence의 `already_reconciled` 뒤에만 caller/router가 `complete-milestone mode=close`를 실행한다. close가 다시 감사를 통과하지 못하면 roadmap 상태를 추측해 바꾸지 않는다.
- 이 모드는 일반 Milestone 문서와 complete-milestone 결과를 수정하지 않고 commit/push하지 않는다.
```bash
python3 agent-ops/skills/common/sync-agent-ui/scripts/sync_state.py reconcile-milestone \
--state agent-ui/.sync-state.json \
--expected-sha256 <sha256> \
--milestone-path agent-roadmap/phase/<phase>/milestones/<milestone>.md \
--closure-evidence '<complete-milestone check-only closure evidence>' \
--validation-evidence '<validate-agent-ui PASS evidence>' \
--reconciled-at <ISO-8601>
```
- 검증된 결과 commit이 있을 때만 `--sync-result-head <commit>`을 추가한다.
### incremental/full 절차
1. **동기화 범위 확정**
@ -137,13 +229,13 @@ description: 정합화된 agent-ui 변경분을 코드에 직접 반영하거나
- `milestone-required`는 코드 구현이 제품 UI 구조나 장기 작업 단위까지 바꾸는 경우 사용한다.
- navigation/information architecture 재정렬, 외부 제품 UI 분석 기반 재설계, 여러 workflow/role/surface를 묶는 작업, 활성 Milestone 범위 밖 작업이 여기에 속한다.
- 이 경우 직접 코드 반영과 plan 생성을 시작하지 않고 `update-roadmap`으로 Milestone/Epic/Task 배치를 먼저 남긴다.
- 이 Milestone에는 `완료 리뷰``agent-ui 상태 반영: 대기`를 남기고, `status: 구현됨` 전환은 plan 완료가 아니라 Milestone 종료 검토 항목으로 둔다.
- exact active Milestone이 확정되면 `prepare-milestone-work`로 UI 매핑을 `.sync-state.json`에 남긴다. Milestone 문서에는 agent-ui 전용 완료 필드를 추가하지 않는다.
- `plan-required` 판정 시 `prepare-code-work` pending entry 외에는 agent-ui 문서 status, baseline sync state, commit/push를 갱신하지 않는다.
- `milestone-required` 판정 시 agent-ui 문서 status, sync state, commit/push를 갱신하지 않는다.
- `milestone-required` 판정 시 agent-ui 문서 status와 baseline sync state, commit/push를 갱신하지 않고 `pending_milestone_work` 매핑만 기록한다.
3. **코드 반영**
- 이 단계는 `execution-route=direct-sync`일 때만 수행한다. `plan-required` 또는 `milestone-required` 판정이면 Step 2의 라우팅 결과를 보고하고 해당 스킬 흐름으로 전환한다.
- `계획` 상태의 view/component/frame 요구사항을 UI 코드에 반영한다.
- `계획` 상태의 view/component 요구사항과 연결된 frame의 layout 근거를 UI 코드에 반영한다.
- existing component, route, shell, widget 구조를 우선하고 새 구조를 임의로 만들지 않는다.
- definition이 요구하는 정보 우선순위, region, action, state, component 참조를 코드 반영 기준으로 삼는다.
- wireframe/frame은 layout, density, visual relationship 보조 근거로만 사용하고 definition을 대체하지 않는다.
@ -159,9 +251,24 @@ description: 정합화된 agent-ui 변경분을 코드에 직접 반영하거나
5. **sync state와 commit/push**
- 이 단계는 `execution-route=direct-sync`에서 코드 반영, 검증, agent-ui status/code evidence 갱신이 모두 통과한 경우에만 수행한다.
- 검증이 통과하면 agent-ui/code 변경을 먼저 commit한다.
- 방금 만든 sync 결과 commit hash를 `agent-ui/.sync-state.json``last_synced_head`에 기록한다.
- `.sync-state.json`이 없으면 `agent-ops/skills/common/_templates/agent-ui/sync-state-template.json` 기준으로 새로 만든다.
- `.sync-state.json`에는 `last_sync_mode`, `last_synced_at`, `agent_ui_paths`, `code_paths`, `notes`를 갱신한다.
- 방금 만든 sync 결과 commit hash와 `.sync-state.json`의 최신 SHA-256을 bundled helper `record-sync`에 넘긴다.
- helper가 `last_sync_mode`, `last_synced_head`, `last_synced_at`, `agent_ui_paths`, `code_paths`, `notes`를 갱신하고 기존 code/Milestone pending·reconciled 기록을 보존하게 한다.
```bash
python3 agent-ops/skills/common/sync-agent-ui/scripts/sync_state.py record-sync \
--state agent-ui/.sync-state.json \
--expected-sha256 <sha256> \
--sync-mode <incremental|full> \
--sync-result-head <sync-result-commit> \
--synced-at <ISO-8601> \
--agent-ui-path <agent-ui-path> \
--code-path <code-path> \
--note '<sync evidence>'
```
- agent-ui/code 경로와 note는 필요한 만큼 옵션을 반복한다.
- legacy baseline migration은 `--sync-mode migration`을 사용하고 code 반영이 없으면 `--code-path`를 생략한다.
- `.sync-state.json` 변경을 별도 commit한다.
- commit message는 예를 들어 `sync: apply agent-ui to code``sync: record agent-ui sync state`처럼 목적이 분리되게 쓴다.
- `push=true`이면 두 commit을 push한다.
@ -181,13 +288,21 @@ description: 정합화된 agent-ui 변경분을 코드에 직접 반영하거나
- [ ] 현재 sync 범위에 미해결 USER_REVIEW가 없는가
- [ ] 코드 반영 전에 `direct-sync`, `plan-required`, `milestone-required` 중 하나로 판정했는가
- [ ] `plan-required` 판정에서 직접 코드 변경, status 구현됨 전환, baseline sync state 갱신, commit/push를 하지 않고 pending entry만 기록했는가
- [ ] `milestone-required` 판정에서 직접 코드 변경, status 구현됨 전환, sync state 갱신, commit/push를 하지 않았는가
- [ ] `milestone-required` 판정에서 직접 코드 변경, status 구현됨 전환, baseline sync state 갱신, commit/push를 하지 않고 exact Milestone pending mapping만 기록했는가
- [ ] `direct-sync` 판정에서 코드 반영과 검증이 통과한 항목은 같은 실행 안에서 agent-ui 문서 status를 `구현됨`으로 바꾸고 code evidence를 남겼는가
- [ ] `prepare-code-work`가 exact task path와 활성 agent-ui 문서를 unique pending entry로 기록했는가
- [ ] refinement/reindex된 pending task는 전체 결과를 닫는 child 하나로 rebind되어 이전 task path가 남지 않았는가
- [ ] view/component는 `status_paths`, frame-view는 validation-only `frame_paths`로 분리했는가
- [ ] 하나의 status/frame path가 둘 이상의 pending task에 배정되지 않았는가
- [ ] state helper에 inspection 시점 SHA-256을 전달해 directory lock 안에서 concurrent/stale write를 차단했는가
- [ ] `prepare-code-work`가 일반 plan/review 문서에 agent-ui 전용 completion section을 요구하지 않았는가
- [ ] `reconcile-completion`이 exact completion log, code path, verification requirement를 확인했는가
- [ ] `reconcile-completion``update-agent-ui` 다음 `validate-agent-ui sync-intent=skip` 순서로 실행했는가
- [ ] reconcile 실패 시 pending entry를 유지하고 성공 시에만 제거했는가
- [ ] reconcile 성공과 idempotency를 `reconciled_code_work`로 판정했는가
- [ ] Milestone mapping은 roadmap 문서가 아니라 `pending_milestone_work`에만 기록했는가
- [ ] Milestone reconcile 전에 `complete-milestone check-only`의 closure-ready 근거를 확인했는가
- [ ] Milestone reconcile 성공 뒤에만 caller/router가 `complete-milestone close`로 진행하는가
- [ ] 검증되지 않은 `sync-result-head``last_synced_head`를 바꾸지 않았는가
- [ ] `가정` 또는 `불명확` 항목을 코드로 반영하지 않았는가
- [ ] `direct-sync`로 코드 반영된 항목의 status가 `구현됨`이고 code evidence가 있는가
@ -196,6 +311,7 @@ description: 정합화된 agent-ui 변경분을 코드에 직접 반영하거나
- [ ] 실패 또는 차단 시 재개 진입점과 남은 변경 파일이 USER_REVIEW에 기록되었는가
- [ ] `direct-sync` 성공 시 sync 결과 commit과 `.sync-state.json` commit이 분리되었는가
- [ ] `direct-sync` 성공 시 `.sync-state.json.last_synced_head`가 sync 결과 commit hash를 가리키는가
- [ ] direct-sync/baseline state 갱신도 helper를 사용해 code/Milestone pending·reconciled 기록을 보존했는가
- [ ] unrelated dirty file이 sync commit에 포함되지 않았는가
- 검증 실패 시: 에이전트가 해결할 수 없으면 `agent-ui/USER_REVIEW.md`에 남기고 중단한다.
@ -204,17 +320,20 @@ description: 정합화된 agent-ui 변경분을 코드에 직접 반영하거나
```md
## agent-ui 코드 동기화 결과: <PASS|PLAN_REQUIRED|MILESTONE_REQUIRED|PREPARED|RECONCILED|ALREADY_RECONCILED|USER_REVIEW|FAIL>
- Mode: <incremental|full|prepare-code-work|reconcile-completion>
- Mode: <incremental|full|prepare-code-work|reconcile-completion|prepare-milestone-work|reconcile-milestone-completion>
- Scope: <scope>
- Execution Route: <direct-sync|plan-required|milestone-required|not-applicable>
- Task Mapping: <task-path와 pending 상태 또는 없음>
- Milestone Mapping: <milestone-path와 pending 상태 또는 없음>
- Completion Log: <path 또는 없음>
- Closure Evidence: <Milestone check-only 근거 또는 없음>
- Agent UI Changes: <파일 목록 또는 없음>
- Code Changes: <파일 목록 또는 없음>
- Status Updates: <구현됨 전환 목록 또는 없음>
- Verification: <명령과 결과>
- Sync Commit: <hash 또는 없음>
- Sync State Commit/Push: <완료/생략/실패>
- State Transition: <prepared|already_prepared|replaced|rebound|reconciled|already_reconciled|없음>
- USER_REVIEW: <생성/갱신/없음>
- Remaining Issues: <목록 또는 없음>
```
@ -226,9 +345,14 @@ description: 정합화된 agent-ui 변경분을 코드에 직접 반영하거나
- `가정` 또는 `불명확` 상태 항목을 코드로 구현하지 않는다.
- `plan-required` 또는 `milestone-required`로 판정된 작업을 같은 `sync-agent-ui` 실행에서 직접 구현하지 않는다.
- 일반 plan/review/complete.log에 agent-ui 전용 completion schema나 status 전환 책임을 추가하지 않는다.
- 일반 Milestone 문서나 roadmap 스킬에 agent-ui 전용 완료 필드나 status 전환 책임을 추가하지 않는다.
- matching pending entry 없이 `complete.log`만 보고 agent-ui 문서를 추측해 갱신하지 않는다.
- frame-view 문서에 `status`를 추가하거나 `구현됨` 전환을 적용하지 않는다.
- `.sync-state.json`을 bundled helper 밖에서 수동 read-modify-write하지 않는다.
- refinement/reindex 뒤 이전 task path의 pending entry를 남기거나 같은 status/frame path를 여러 pending task에 중복 배정하지 않는다.
- reconcile 실패 시 pending entry를 제거하거나 `last_synced_head`를 추정하지 않는다.
- `execution-route=milestone-required`로 라우팅된 agent-ui 코드 작업의 일반 plan 완료만으로 agent-ui status를 일괄 `구현됨`으로 바꾸지 않는다. 종료 검토 통과와 code evidence가 있는 항목만 반영한다.
- `complete-milestone check-only` 종료 가능 근거 없이 Milestone pending entry를 reconcile하거나 close를 계속하지 않는다.
- `execution-route=milestone-required`로 라우팅된 agent-ui 코드 작업의 일반 plan 완료만으로 agent-ui status를 일괄 `구현됨`으로 바꾸지 않는다. 종료 감사와 code evidence가 있는 항목만 반영한다.
- 검증 실패나 사용자 판단 차단 상태에서 commit/push하지 않는다.
- `.sync-state.json`을 agent-ui 변경분 판정 대상으로 삼지 않는다.
- 정의서와 충돌하는 wireframe만을 근거로 코드 구현을 확정하지 않는다.

View file

@ -0,0 +1,874 @@
#!/usr/bin/env python3
"""Atomically manage plan- and Milestone-required agent-ui work mappings."""
from __future__ import annotations
import argparse
import fcntl
import hashlib
import json
import os
import re
import stat
import tempfile
from contextlib import contextmanager
from datetime import datetime
from functools import wraps
from pathlib import Path, PurePosixPath
from typing import Any
SCHEMA_VERSION = 4
SHA_PATTERN = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$")
STATUS_PATH_PATTERN = re.compile(
r"^agent-ui/definition/(?:views|components)/[a-z0-9][a-z0-9/-]*/index\.md$"
)
FRAME_PATH_PATTERN = re.compile(
r"^agent-ui/frame/views/[a-z0-9][a-z0-9/-]*/index\.md$"
)
SCOPE_PATTERN = re.compile(r"^(?:all|view:[a-z0-9][a-z0-9-]*|component:[a-z0-9][a-z0-9/-]*)$")
MILESTONE_PATH_PATTERN = re.compile(
r"^agent-roadmap/phase/[a-z0-9][a-z0-9-]*/milestones/"
r"[a-z0-9][a-z0-9-]*\.md$"
)
class StateError(ValueError):
pass
@contextmanager
def _state_lock(path: Path):
directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
fcntl.flock(directory_fd, fcntl.LOCK_EX)
yield
finally:
fcntl.flock(directory_fd, fcntl.LOCK_UN)
os.close(directory_fd)
def _locked_state_command(handler):
@wraps(handler)
def wrapped(args: argparse.Namespace) -> dict[str, Any]:
with _state_lock(args.state):
return handler(args)
return wrapped
def _stable_unique(values: list[str]) -> list[str]:
return list(dict.fromkeys(values))
def _require_relative_path(value: str, *, prefix: str | None = None) -> str:
path = PurePosixPath(value)
if not value or path.is_absolute() or ".." in path.parts or "." in path.parts:
raise StateError(f"invalid repository-relative path: {value!r}")
normalized = path.as_posix()
if prefix and not normalized.startswith(prefix):
raise StateError(f"path must start with {prefix!r}: {value!r}")
return normalized
def _require_task_path(value: str) -> str:
normalized = _require_relative_path(value, prefix="agent-task/")
if normalized == "agent-task" or normalized.startswith("agent-task/archive/"):
raise StateError(f"task path must be the original active path: {value!r}")
return normalized.rstrip("/")
def _require_milestone_path(value: str) -> str:
normalized = _require_relative_path(value, prefix="agent-roadmap/phase/")
if not MILESTONE_PATH_PATTERN.fullmatch(normalized):
raise StateError(f"milestone path must be an active Milestone document: {value!r}")
return normalized
def _require_timestamp(value: str) -> str:
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise StateError(f"invalid ISO-8601 timestamp: {value!r}") from exc
if parsed.tzinfo is None:
raise StateError(f"timestamp must include a timezone: {value!r}")
return value
def _require_string_list(value: Any, *, field: str) -> list[str]:
if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
raise StateError(f"{field} must be a list of non-empty strings")
return _stable_unique(value)
def _require_object_list(value: Any, *, field: str) -> list[dict[str, Any]]:
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
raise StateError(f"{field} must be a list of objects")
return value
def _normalize_pending_entry(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
raise StateError("pending_code_work entries must be objects")
legacy_paths = _require_string_list(raw.get("agent_ui_paths", []), field="agent_ui_paths")
status_paths = _require_string_list(raw.get("status_paths", []), field="status_paths")
frame_paths = _require_string_list(raw.get("frame_paths", []), field="frame_paths")
if legacy_paths:
status_paths.extend(path for path in legacy_paths if not path.startswith("agent-ui/frame/"))
frame_paths.extend(path for path in legacy_paths if path.startswith("agent-ui/frame/"))
status_paths = [
_require_relative_path(path, prefix="agent-ui/definition/")
for path in _stable_unique(status_paths)
]
frame_paths = [
_require_relative_path(path, prefix="agent-ui/frame/")
for path in _stable_unique(frame_paths)
]
if not status_paths:
raise StateError("pending_code_work requires at least one status_path")
if not all(STATUS_PATH_PATTERN.fullmatch(path) for path in status_paths):
raise StateError("status_paths must be active view/component index.md paths")
if not all(FRAME_PATH_PATTERN.fullmatch(path) for path in frame_paths):
raise StateError("frame_paths must be active frame-view index.md paths")
if set(status_paths) & set(frame_paths):
raise StateError("status_paths and frame_paths must be disjoint")
code_paths = [
_require_relative_path(path)
for path in _require_string_list(raw.get("code_paths", []), field="code_paths")
]
if not code_paths:
raise StateError("pending_code_work requires at least one code_path")
if any(path.startswith(("agent-ui/", "agent-task/")) for path in code_paths):
raise StateError("code_paths must not point to agent-ui or agent-task artifacts")
scope = str(raw.get("scope", "")).strip() or "all"
if not SCOPE_PATTERN.fullmatch(scope):
raise StateError(f"invalid sync scope: {scope!r}")
return {
"task_path": _require_task_path(str(raw.get("task_path", ""))),
"scope": scope,
"status_paths": status_paths,
"frame_paths": frame_paths,
"code_paths": code_paths,
"verification_requirements": _require_string_list(
raw.get("verification_requirements", []),
field="verification_requirements",
),
"prepared_at": _require_timestamp(str(raw.get("prepared_at", ""))),
"state": "pending",
}
def _normalize_pending_milestone_entry(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
raise StateError("pending_milestone_work entries must be objects")
entry = _normalize_pending_entry(
{
**raw,
"task_path": "agent-task/milestone-placeholder",
}
)
return {
"milestone_path": _require_milestone_path(str(raw.get("milestone_path", ""))),
"scope": entry["scope"],
"status_paths": entry["status_paths"],
"frame_paths": entry["frame_paths"],
"code_paths": entry["code_paths"],
"verification_requirements": entry["verification_requirements"],
"prepared_at": entry["prepared_at"],
"state": "pending",
}
def _normalize_reconciled_entry(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
raise StateError("reconciled_code_work entries must be objects")
scope = str(raw.get("scope", "")).strip() or "all"
if not SCOPE_PATTERN.fullmatch(scope):
raise StateError(f"invalid reconciled sync scope: {scope!r}")
entry = {
"task_path": _require_task_path(str(raw.get("task_path", ""))),
"completion_log": _require_relative_path(
str(raw.get("completion_log", "")),
prefix="agent-task/archive/",
),
"scope": scope,
"status_paths": [
_require_relative_path(path, prefix="agent-ui/definition/")
for path in _require_string_list(raw.get("status_paths", []), field="status_paths")
],
"frame_paths": [
_require_relative_path(path, prefix="agent-ui/frame/")
for path in _require_string_list(raw.get("frame_paths", []), field="frame_paths")
],
"code_paths": [
_require_relative_path(path)
for path in _require_string_list(raw.get("code_paths", []), field="code_paths")
],
"validation_evidence": str(raw.get("validation_evidence", "")).strip(),
"reconciled_at": _require_timestamp(str(raw.get("reconciled_at", ""))),
"sync_result_head": raw.get("sync_result_head"),
}
if not entry["status_paths"] or not entry["code_paths"] or not entry["validation_evidence"]:
raise StateError("reconciled_code_work entry is incomplete")
if not all(STATUS_PATH_PATTERN.fullmatch(path) for path in entry["status_paths"]):
raise StateError("reconciled status_paths must be active view/component index.md paths")
if not all(FRAME_PATH_PATTERN.fullmatch(path) for path in entry["frame_paths"]):
raise StateError("reconciled frame_paths must be active frame-view index.md paths")
if any(path.startswith(("agent-ui/", "agent-task/")) for path in entry["code_paths"]):
raise StateError("reconciled code_paths must not point to agent-ui or agent-task artifacts")
if entry["sync_result_head"] is not None and not SHA_PATTERN.fullmatch(
str(entry["sync_result_head"])
):
raise StateError("sync_result_head must be a 40- or 64-character lowercase hex hash")
return entry
def _normalize_reconciled_milestone_entry(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
raise StateError("reconciled_milestone_work entries must be objects")
synthetic = _normalize_reconciled_entry(
{
**raw,
"task_path": "agent-task/milestone-placeholder",
"completion_log": "agent-task/archive/2000/01/milestone-placeholder/complete.log",
}
)
closure_evidence = str(raw.get("closure_evidence", "")).strip()
if not closure_evidence:
raise StateError("reconciled_milestone_work requires closure_evidence")
return {
"milestone_path": _require_milestone_path(str(raw.get("milestone_path", ""))),
"closure_evidence": closure_evidence,
"scope": synthetic["scope"],
"status_paths": synthetic["status_paths"],
"frame_paths": synthetic["frame_paths"],
"code_paths": synthetic["code_paths"],
"validation_evidence": synthetic["validation_evidence"],
"reconciled_at": synthetic["reconciled_at"],
"sync_result_head": synthetic["sync_result_head"],
}
def _normalize_state(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
raise StateError("sync state must be a JSON object")
version = raw.get("schema_version")
if version not in {1, 2, 3, 4}:
raise StateError(f"unsupported schema_version: {version!r}")
state = dict(raw)
state["schema_version"] = SCHEMA_VERSION
state["pending_code_work"] = [
_normalize_pending_entry(entry)
for entry in _require_object_list(
raw.get("pending_code_work", []),
field="pending_code_work",
)
]
state["reconciled_code_work"] = [
_normalize_reconciled_entry(entry)
for entry in _require_object_list(
raw.get("reconciled_code_work", []),
field="reconciled_code_work",
)
]
state["pending_milestone_work"] = [
_normalize_pending_milestone_entry(entry)
for entry in _require_object_list(
raw.get("pending_milestone_work", []),
field="pending_milestone_work",
)
]
state["reconciled_milestone_work"] = [
_normalize_reconciled_milestone_entry(entry)
for entry in _require_object_list(
raw.get("reconciled_milestone_work", []),
field="reconciled_milestone_work",
)
]
pending_keys = [entry["task_path"] for entry in state["pending_code_work"]]
if len(pending_keys) != len(set(pending_keys)):
raise StateError("pending_code_work contains duplicate task_path values")
milestone_keys = [
entry["milestone_path"] for entry in state["pending_milestone_work"]
]
if len(milestone_keys) != len(set(milestone_keys)):
raise StateError("pending_milestone_work contains duplicate milestone_path values")
pending_entries = state["pending_code_work"] + state["pending_milestone_work"]
pending_status_paths = [
path for entry in pending_entries for path in entry["status_paths"]
]
if len(pending_status_paths) != len(set(pending_status_paths)):
raise StateError("pending work assigns one status_path to multiple owners")
pending_frame_paths = [
path for entry in pending_entries for path in entry["frame_paths"]
]
if len(pending_frame_paths) != len(set(pending_frame_paths)):
raise StateError("pending work assigns one frame_path to multiple owners")
reconciled_keys = [
(entry["task_path"], entry["completion_log"])
for entry in state["reconciled_code_work"]
]
if len(reconciled_keys) != len(set(reconciled_keys)):
raise StateError("reconciled_code_work contains duplicate task/completion pairs")
reconciled_milestone_keys = [
(entry["milestone_path"], entry["closure_evidence"])
for entry in state["reconciled_milestone_work"]
]
if len(reconciled_milestone_keys) != len(set(reconciled_milestone_keys)):
raise StateError(
"reconciled_milestone_work contains duplicate milestone/evidence pairs"
)
notes = raw.get("notes", [])
state["notes"] = _require_string_list(notes, field="notes")
return state
def _load_state(path: Path, expected_sha256: str) -> tuple[dict[str, Any], str]:
if not path.is_file():
raise StateError(f"sync state does not exist: {path}")
if not re.fullmatch(r"[0-9a-f]{64}", expected_sha256):
raise StateError("expected_sha256 must be a 64-character lowercase hex digest")
payload = path.read_bytes()
actual_sha256 = hashlib.sha256(payload).hexdigest()
if actual_sha256 != expected_sha256:
raise StateError(
"sync state changed after inspection: "
f"expected={expected_sha256} actual={actual_sha256}"
)
try:
raw = json.loads(payload)
except json.JSONDecodeError as exc:
raise StateError(f"invalid sync state JSON: {exc}") from exc
return _normalize_state(raw), actual_sha256
def _write_state(path: Path, state: dict[str, Any]) -> str:
payload = (json.dumps(state, ensure_ascii=False, indent=2) + "\n").encode()
mode = stat.S_IMODE(path.stat().st_mode)
temporary: Path | None = None
try:
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as handle:
temporary = Path(handle.name)
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary, mode)
os.replace(temporary, path)
temporary = None
directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
return hashlib.sha256(payload).hexdigest()
def _pending_entry_from_args(args: argparse.Namespace) -> dict[str, Any]:
return _normalize_pending_entry(
{
"task_path": args.task_path,
"scope": args.scope,
"status_paths": args.status_path,
"frame_paths": args.frame_path,
"code_paths": args.code_path,
"verification_requirements": args.verification_requirement,
"prepared_at": args.prepared_at,
"state": "pending",
}
)
def _pending_milestone_entry_from_args(args: argparse.Namespace) -> dict[str, Any]:
return _normalize_pending_milestone_entry(
{
"milestone_path": args.milestone_path,
"scope": args.scope,
"status_paths": args.status_path,
"frame_paths": args.frame_path,
"code_paths": args.code_path,
"verification_requirements": args.verification_requirement,
"prepared_at": args.prepared_at,
"state": "pending",
}
)
@_locked_state_command
def _prepare(args: argparse.Namespace) -> dict[str, Any]:
state, before_sha256 = _load_state(args.state, args.expected_sha256)
requested = _pending_entry_from_args(args)
previous_task_path = (
_require_task_path(args.previous_task_path) if args.previous_task_path else None
)
if previous_task_path == requested["task_path"]:
raise StateError("previous_task_path must differ from task_path")
matches = [
index
for index, entry in enumerate(state["pending_code_work"])
if entry["task_path"] == requested["task_path"]
]
if len(matches) > 1:
raise StateError(f"multiple pending entries for {requested['task_path']}")
if previous_task_path is not None:
previous_matches = [
index
for index, entry in enumerate(state["pending_code_work"])
if entry["task_path"] == previous_task_path
]
if len(previous_matches) != 1:
raise StateError(
f"expected exactly one previous pending entry for {previous_task_path}, "
f"found {len(previous_matches)}"
)
if matches:
raise StateError(f"new task_path already has a pending entry: {requested['task_path']}")
existing = state["pending_code_work"][previous_matches[0]]
semantic_fields = (
"scope",
"status_paths",
"frame_paths",
"code_paths",
"verification_requirements",
"state",
)
changed = any(existing[field] != requested[field] for field in semantic_fields)
if changed and not args.replace:
raise StateError(
"rebound mapping changes scope or evidence; verify the closure child "
"and rerun with --replace"
)
del state["pending_code_work"][previous_matches[0]]
state["pending_code_work"].append(requested)
status = "rebound"
elif matches:
index = matches[0]
existing = state["pending_code_work"][index]
semantic_fields = (
"task_path",
"scope",
"status_paths",
"frame_paths",
"code_paths",
"verification_requirements",
"state",
)
if all(existing[field] == requested[field] for field in semantic_fields):
status = "already_prepared"
elif args.replace:
state["pending_code_work"][index] = requested
status = "replaced"
else:
raise StateError(
f"pending entry differs for {requested['task_path']}; "
"verify the new plan scope and rerun with --replace"
)
else:
state["pending_code_work"].append(requested)
status = "prepared"
state = _normalize_state(state)
after_sha256 = _write_state(args.state, state)
return {
"status": status,
"task_path": requested["task_path"],
"previous_task_path": previous_task_path,
"before_sha256": before_sha256,
"after_sha256": after_sha256,
}
@_locked_state_command
def _prepare_milestone(args: argparse.Namespace) -> dict[str, Any]:
state, before_sha256 = _load_state(args.state, args.expected_sha256)
requested = _pending_milestone_entry_from_args(args)
if not Path(requested["milestone_path"]).is_file():
raise StateError(
f"active Milestone does not exist: {requested['milestone_path']}"
)
matches = [
index
for index, entry in enumerate(state["pending_milestone_work"])
if entry["milestone_path"] == requested["milestone_path"]
]
if len(matches) > 1:
raise StateError(
f"multiple pending entries for {requested['milestone_path']}"
)
if matches:
index = matches[0]
existing = state["pending_milestone_work"][index]
semantic_fields = (
"milestone_path",
"scope",
"status_paths",
"frame_paths",
"code_paths",
"verification_requirements",
"state",
)
if all(existing[field] == requested[field] for field in semantic_fields):
status = "already_prepared"
elif args.replace:
state["pending_milestone_work"][index] = requested
status = "replaced"
else:
raise StateError(
f"pending entry differs for {requested['milestone_path']}; "
"verify the Milestone scope and rerun with --replace"
)
else:
state["pending_milestone_work"].append(requested)
status = "prepared"
state = _normalize_state(state)
after_sha256 = _write_state(args.state, state)
return {
"status": status,
"milestone_path": requested["milestone_path"],
"before_sha256": before_sha256,
"after_sha256": after_sha256,
}
def _completion_log_location(path: Path) -> tuple[Path, str]:
workspace = Path.cwd().resolve()
resolved = path.resolve()
try:
relative = resolved.relative_to(workspace)
except ValueError as exc:
raise StateError(f"completion log is outside the current workspace: {path}") from exc
normalized = _require_relative_path(relative.as_posix(), prefix="agent-task/archive/")
return resolved, normalized
def _validate_completion_log(path: Path, task_path: str) -> str:
resolved, normalized = _completion_log_location(path)
if not resolved.is_file():
raise StateError(f"completion log does not exist: {path}")
archive_parts = PurePosixPath(normalized).parts
if len(archive_parts) < 6 or archive_parts[-1] != "complete.log":
raise StateError("completion log must be under agent-task/archive/YYYY/MM/<task>/")
if not re.fullmatch(r"[0-9]{4}", archive_parts[2]) or not re.fullmatch(
r"(?:0[1-9]|1[0-2])",
archive_parts[3],
):
raise StateError("completion log archive path must contain YYYY/MM")
archived_task_parts = archive_parts[4:-1]
original_task_parts = PurePosixPath(task_path.removeprefix("agent-task/")).parts
same_parent = archived_task_parts[:-1] == original_task_parts[:-1]
final_name = archived_task_parts[-1] if archived_task_parts else ""
original_final_name = original_task_parts[-1]
suffix_match = re.fullmatch(re.escape(original_final_name) + r"(?:_[0-9]+)?", final_name)
if not same_parent or suffix_match is None:
raise StateError("completion log archive path does not match task_path")
text = resolved.read_text()
task_name = task_path.removeprefix("agent-task/")
if f"# Complete - {task_name}" not in text.splitlines()[:3]:
raise StateError("completion log task header does not match task_path")
if "## 루프 이력" not in text:
raise StateError("completion log has no loop history")
terminal = re.compile(r"^\|[^|]*\|[^|]*\|\s*(?:PASS|RESOLVED|PASS/RESOLVED)\s*\|", re.MULTILINE)
if not terminal.search(text):
raise StateError("completion log has no terminal PASS/RESOLVED row")
return normalized
@_locked_state_command
def _reconcile(args: argparse.Namespace) -> dict[str, Any]:
state, before_sha256 = _load_state(args.state, args.expected_sha256)
task_path = _require_task_path(args.task_path)
completion_log = _validate_completion_log(args.completion_log, task_path)
completed = [
entry
for entry in state["reconciled_code_work"]
if entry["task_path"] == task_path and entry["completion_log"] == completion_log
]
if completed:
state = _normalize_state(state)
after_sha256 = _write_state(args.state, state)
return {
"status": "already_reconciled",
"task_path": task_path,
"before_sha256": before_sha256,
"after_sha256": after_sha256,
}
matches = [
index
for index, entry in enumerate(state["pending_code_work"])
if entry["task_path"] == task_path
]
if len(matches) != 1:
raise StateError(f"expected exactly one pending entry for {task_path}, found {len(matches)}")
pending = state["pending_code_work"][matches[0]]
sync_result_head = args.sync_result_head
if sync_result_head is not None and not SHA_PATTERN.fullmatch(sync_result_head):
raise StateError("sync_result_head must be a 40- or 64-character lowercase hex hash")
reconciled = _normalize_reconciled_entry(
{
"task_path": task_path,
"completion_log": completion_log,
"scope": pending["scope"],
"status_paths": pending["status_paths"],
"frame_paths": pending["frame_paths"],
"code_paths": pending["code_paths"],
"validation_evidence": args.validation_evidence,
"reconciled_at": args.reconciled_at,
"sync_result_head": sync_result_head,
}
)
del state["pending_code_work"][matches[0]]
state["reconciled_code_work"].append(reconciled)
state["last_sync_mode"] = "plan-reconcile"
state["last_synced_at"] = args.reconciled_at
state["agent_ui_paths"] = pending["status_paths"] + pending["frame_paths"]
state["code_paths"] = pending["code_paths"]
if sync_result_head is not None:
state["last_synced_head"] = sync_result_head
baseline_evidence = f"baseline=updated:{sync_result_head}"
else:
baseline_evidence = "baseline=preserved:no validated sync_result_head"
state["notes"].append(
f"plan reconcile: task={task_path}; completion={completion_log}; "
f"validation={args.validation_evidence}; {baseline_evidence}"
)
state = _normalize_state(state)
after_sha256 = _write_state(args.state, state)
return {
"status": "reconciled",
"task_path": task_path,
"before_sha256": before_sha256,
"after_sha256": after_sha256,
}
@_locked_state_command
def _reconcile_milestone(args: argparse.Namespace) -> dict[str, Any]:
state, before_sha256 = _load_state(args.state, args.expected_sha256)
milestone_path = _require_milestone_path(args.milestone_path)
closure_evidence = args.closure_evidence.strip()
if not closure_evidence:
raise StateError("closure_evidence must be non-empty")
matches = [
index
for index, entry in enumerate(state["pending_milestone_work"])
if entry["milestone_path"] == milestone_path
]
if not matches:
completed = [
entry
for entry in state["reconciled_milestone_work"]
if entry["milestone_path"] == milestone_path
and entry["closure_evidence"] == closure_evidence
]
if completed:
state = _normalize_state(state)
after_sha256 = _write_state(args.state, state)
return {
"status": "already_reconciled",
"milestone_path": milestone_path,
"before_sha256": before_sha256,
"after_sha256": after_sha256,
}
if len(matches) != 1:
raise StateError(
f"expected exactly one pending entry for {milestone_path}, "
f"found {len(matches)}"
)
if not Path(milestone_path).is_file():
raise StateError(f"active Milestone does not exist: {milestone_path}")
pending = state["pending_milestone_work"][matches[0]]
sync_result_head = args.sync_result_head
if sync_result_head is not None and not SHA_PATTERN.fullmatch(sync_result_head):
raise StateError("sync_result_head must be a 40- or 64-character lowercase hex hash")
reconciled = _normalize_reconciled_milestone_entry(
{
"milestone_path": milestone_path,
"closure_evidence": closure_evidence,
"scope": pending["scope"],
"status_paths": pending["status_paths"],
"frame_paths": pending["frame_paths"],
"code_paths": pending["code_paths"],
"validation_evidence": args.validation_evidence,
"reconciled_at": args.reconciled_at,
"sync_result_head": sync_result_head,
}
)
del state["pending_milestone_work"][matches[0]]
state["reconciled_milestone_work"].append(reconciled)
state["last_sync_mode"] = "milestone-reconcile"
state["last_synced_at"] = args.reconciled_at
state["agent_ui_paths"] = pending["status_paths"] + pending["frame_paths"]
state["code_paths"] = pending["code_paths"]
if sync_result_head is not None:
state["last_synced_head"] = sync_result_head
baseline_evidence = f"baseline=updated:{sync_result_head}"
else:
baseline_evidence = "baseline=preserved:no validated sync_result_head"
state["notes"].append(
f"milestone reconcile: milestone={milestone_path}; "
f"closure={closure_evidence}; validation={args.validation_evidence}; "
f"{baseline_evidence}"
)
state = _normalize_state(state)
after_sha256 = _write_state(args.state, state)
return {
"status": "reconciled",
"milestone_path": milestone_path,
"before_sha256": before_sha256,
"after_sha256": after_sha256,
}
@_locked_state_command
def _record_sync(args: argparse.Namespace) -> dict[str, Any]:
state, before_sha256 = _load_state(args.state, args.expected_sha256)
if not SHA_PATTERN.fullmatch(args.sync_result_head):
raise StateError("sync_result_head must be a 40- or 64-character lowercase hex hash")
synced_at = _require_timestamp(args.synced_at)
agent_ui_paths = [
_require_relative_path(path, prefix="agent-ui/")
for path in _stable_unique(args.agent_ui_path)
]
code_paths = [
_require_relative_path(path) for path in _stable_unique(args.code_path)
]
if not agent_ui_paths:
raise StateError("record-sync requires agent_ui_paths")
if args.sync_mode in {"incremental", "full"} and not code_paths:
raise StateError("incremental/full record-sync requires code_paths")
if any(path.startswith(("agent-ui/", "agent-task/")) for path in code_paths):
raise StateError("record-sync code_paths must not point to agent-ui or agent-task artifacts")
state["last_sync_mode"] = args.sync_mode
state["last_synced_head"] = args.sync_result_head
state["last_synced_at"] = synced_at
state["agent_ui_paths"] = agent_ui_paths
state["code_paths"] = code_paths
state["notes"] = _stable_unique(state["notes"] + args.note)
state = _normalize_state(state)
after_sha256 = _write_state(args.state, state)
return {
"status": "sync_recorded",
"before_sha256": before_sha256,
"after_sha256": after_sha256,
"sync_result_head": args.sync_result_head,
}
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
prepare = subparsers.add_parser("prepare")
prepare.add_argument("--state", type=Path, required=True)
prepare.add_argument("--expected-sha256", required=True)
prepare.add_argument("--task-path", required=True)
prepare.add_argument("--previous-task-path")
prepare.add_argument("--scope", default="all")
prepare.add_argument("--status-path", action="append", default=[], required=True)
prepare.add_argument("--frame-path", action="append", default=[])
prepare.add_argument("--code-path", action="append", default=[], required=True)
prepare.add_argument("--verification-requirement", action="append", default=[])
prepare.add_argument("--prepared-at", required=True)
prepare.add_argument("--replace", action="store_true")
prepare.set_defaults(handler=_prepare)
prepare_milestone = subparsers.add_parser("prepare-milestone")
prepare_milestone.add_argument("--state", type=Path, required=True)
prepare_milestone.add_argument("--expected-sha256", required=True)
prepare_milestone.add_argument("--milestone-path", required=True)
prepare_milestone.add_argument("--scope", default="all")
prepare_milestone.add_argument(
"--status-path",
action="append",
default=[],
required=True,
)
prepare_milestone.add_argument("--frame-path", action="append", default=[])
prepare_milestone.add_argument(
"--code-path",
action="append",
default=[],
required=True,
)
prepare_milestone.add_argument(
"--verification-requirement",
action="append",
default=[],
)
prepare_milestone.add_argument("--prepared-at", required=True)
prepare_milestone.add_argument("--replace", action="store_true")
prepare_milestone.set_defaults(handler=_prepare_milestone)
reconcile = subparsers.add_parser("reconcile")
reconcile.add_argument("--state", type=Path, required=True)
reconcile.add_argument("--expected-sha256", required=True)
reconcile.add_argument("--task-path", required=True)
reconcile.add_argument("--completion-log", type=Path, required=True)
reconcile.add_argument("--validation-evidence", required=True)
reconcile.add_argument("--reconciled-at", required=True)
reconcile.add_argument("--sync-result-head")
reconcile.set_defaults(handler=_reconcile)
reconcile_milestone = subparsers.add_parser("reconcile-milestone")
reconcile_milestone.add_argument("--state", type=Path, required=True)
reconcile_milestone.add_argument("--expected-sha256", required=True)
reconcile_milestone.add_argument("--milestone-path", required=True)
reconcile_milestone.add_argument("--closure-evidence", required=True)
reconcile_milestone.add_argument("--validation-evidence", required=True)
reconcile_milestone.add_argument("--reconciled-at", required=True)
reconcile_milestone.add_argument("--sync-result-head")
reconcile_milestone.set_defaults(handler=_reconcile_milestone)
record_sync = subparsers.add_parser("record-sync")
record_sync.add_argument("--state", type=Path, required=True)
record_sync.add_argument("--expected-sha256", required=True)
record_sync.add_argument(
"--sync-mode",
choices=("incremental", "full", "baseline", "migration"),
required=True,
)
record_sync.add_argument("--sync-result-head", required=True)
record_sync.add_argument("--synced-at", required=True)
record_sync.add_argument("--agent-ui-path", action="append", default=[], required=True)
record_sync.add_argument("--code-path", action="append", default=[])
record_sync.add_argument("--note", action="append", default=[])
record_sync.set_defaults(handler=_record_sync)
return parser
def main() -> int:
args = _parser().parse_args()
try:
result = args.handler(args)
except (OSError, StateError) as exc:
print(json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False))
return 2
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,445 @@
import hashlib
import importlib.util
import json
import os
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).parents[1] / "scripts" / "sync_state.py"
SPEC = importlib.util.spec_from_file_location("sync_state", SCRIPT)
sync_state = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
SPEC.loader.exec_module(sync_state)
def write_json(path: Path, value: dict) -> str:
payload = (json.dumps(value, indent=2) + "\n").encode()
path.write_bytes(payload)
return hashlib.sha256(payload).hexdigest()
class SyncStateTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name)
self.original_cwd = Path.cwd()
os.chdir(self.root)
self.state_path = self.root / "agent-ui" / ".sync-state.json"
self.state_path.parent.mkdir(parents=True)
self.base_state = {
"schema_version": 1,
"surface_type": "ops-dev",
"baseline_mode": "migration",
"last_sync_mode": "baseline",
"last_synced_head": "a" * 40,
"last_synced_at": "2026-01-01T00:00:00Z",
"agent_ui_paths": [],
"code_paths": [],
"notes": [],
}
def tearDown(self):
os.chdir(self.original_cwd)
self.temporary.cleanup()
def prepare_args(self, digest: str, **overrides):
values = {
"state": self.state_path,
"expected_sha256": digest,
"task_path": "agent-task/ui_work",
"previous_task_path": None,
"scope": "view:dashboard",
"status_path": ["agent-ui/definition/views/dashboard/index.md"],
"frame_path": ["agent-ui/frame/views/dashboard/index.md"],
"code_path": ["src/dashboard.py"],
"verification_requirement": ["python -m unittest: PASS"],
"prepared_at": "2026-07-29T00:00:00Z",
"replace": False,
}
values.update(overrides)
return type("Args", (), values)
def reconcile_args(self, digest: str, completion_log: Path, **overrides):
values = {
"state": self.state_path,
"expected_sha256": digest,
"task_path": "agent-task/ui_work",
"completion_log": completion_log,
"validation_evidence": "validate-agent-ui scope=view:dashboard PASS",
"reconciled_at": "2026-07-29T01:00:00Z",
"sync_result_head": None,
}
values.update(overrides)
return type("Args", (), values)
def prepare_milestone_args(self, digest: str, **overrides):
values = {
"state": self.state_path,
"expected_sha256": digest,
"milestone_path": (
"agent-roadmap/phase/product/milestones/dashboard-refresh.md"
),
"scope": "view:dashboard",
"status_path": ["agent-ui/definition/views/dashboard/index.md"],
"frame_path": ["agent-ui/frame/views/dashboard/index.md"],
"code_path": ["src/dashboard.py"],
"verification_requirement": ["python -m unittest: PASS"],
"prepared_at": "2026-07-29T00:00:00Z",
"replace": False,
}
values.update(overrides)
return type("Args", (), values)
def reconcile_milestone_args(self, digest: str, **overrides):
values = {
"state": self.state_path,
"expected_sha256": digest,
"milestone_path": (
"agent-roadmap/phase/product/milestones/dashboard-refresh.md"
),
"closure_evidence": "complete-milestone check-only: closure-ready",
"validation_evidence": "validate-agent-ui scope=view:dashboard PASS",
"reconciled_at": "2026-07-29T01:00:00Z",
"sync_result_head": None,
}
values.update(overrides)
return type("Args", (), values)
def record_sync_args(self, digest: str, **overrides):
values = {
"state": self.state_path,
"expected_sha256": digest,
"sync_mode": "incremental",
"sync_result_head": "b" * 40,
"synced_at": "2026-07-29T02:00:00Z",
"agent_ui_path": ["agent-ui/definition/views/dashboard/index.md"],
"code_path": ["src/dashboard.py"],
"note": ["direct sync"],
}
values.update(overrides)
return type("Args", (), values)
def test_prepare_migrates_v1_and_records_typed_paths(self):
digest = write_json(self.state_path, self.base_state)
result = sync_state._prepare(self.prepare_args(digest))
state = json.loads(self.state_path.read_text())
self.assertEqual("prepared", result["status"])
self.assertEqual(4, state["schema_version"])
self.assertEqual(
["agent-ui/definition/views/dashboard/index.md"],
state["pending_code_work"][0]["status_paths"],
)
self.assertEqual(
["agent-ui/frame/views/dashboard/index.md"],
state["pending_code_work"][0]["frame_paths"],
)
self.assertEqual([], state["reconciled_code_work"])
self.assertEqual([], state["pending_milestone_work"])
self.assertEqual([], state["reconciled_milestone_work"])
def test_prepare_is_idempotent_and_requires_replace_for_scope_change(self):
digest = write_json(self.state_path, self.base_state)
first = sync_state._prepare(self.prepare_args(digest))
second = sync_state._prepare(
self.prepare_args(
first["after_sha256"],
prepared_at="2026-07-29T00:05:00Z",
)
)
self.assertEqual("already_prepared", second["status"])
with self.assertRaises(sync_state.StateError):
sync_state._prepare(
self.prepare_args(
second["after_sha256"],
code_path=["src/other.py"],
)
)
replaced = sync_state._prepare(
self.prepare_args(
second["after_sha256"],
code_path=["src/other.py"],
replace=True,
)
)
self.assertEqual("replaced", replaced["status"])
def test_prepare_rejects_stale_digest(self):
digest = write_json(self.state_path, self.base_state)
with self.assertRaises(sync_state.StateError):
sync_state._prepare(self.prepare_args("0" * 64))
self.assertEqual(digest, hashlib.sha256(self.state_path.read_bytes()).hexdigest())
def test_prepare_rejects_malformed_digest(self):
digest = write_json(self.state_path, self.base_state)
with self.assertRaises(sync_state.StateError):
sync_state._prepare(self.prepare_args("not-a-sha256"))
self.assertEqual(digest, hashlib.sha256(self.state_path.read_bytes()).hexdigest())
def test_prepare_rebinds_refined_task_path(self):
digest = write_json(self.state_path, self.base_state)
prepared = sync_state._prepare(self.prepare_args(digest))
rebound = sync_state._prepare(
self.prepare_args(
prepared["after_sha256"],
previous_task_path="agent-task/ui_work",
task_path="agent-task/ui_work/01_closure",
)
)
state = json.loads(self.state_path.read_text())
self.assertEqual("rebound", rebound["status"])
self.assertEqual(
"agent-task/ui_work/01_closure",
state["pending_code_work"][0]["task_path"],
)
def test_prepare_rejects_status_path_owned_by_another_task(self):
digest = write_json(self.state_path, self.base_state)
prepared = sync_state._prepare(self.prepare_args(digest))
with self.assertRaises(sync_state.StateError):
sync_state._prepare(
self.prepare_args(
prepared["after_sha256"],
task_path="agent-task/other_ui_work",
frame_path=[],
code_path=["src/other.py"],
)
)
state = json.loads(self.state_path.read_text())
self.assertEqual(1, len(state["pending_code_work"]))
self.assertEqual(
"agent-task/ui_work",
state["pending_code_work"][0]["task_path"],
)
def test_prepare_rebind_requires_replace_for_changed_scope(self):
digest = write_json(self.state_path, self.base_state)
prepared = sync_state._prepare(self.prepare_args(digest))
with self.assertRaises(sync_state.StateError):
sync_state._prepare(
self.prepare_args(
prepared["after_sha256"],
previous_task_path="agent-task/ui_work",
task_path="agent-task/ui_work/01_closure",
code_path=["src/closure.py"],
)
)
state = json.loads(self.state_path.read_text())
self.assertEqual(
"agent-task/ui_work",
state["pending_code_work"][0]["task_path"],
)
def test_prepare_milestone_records_mapping_and_blocks_shared_ui_owner(self):
digest = write_json(self.state_path, self.base_state)
milestone = self.root / (
"agent-roadmap/phase/product/milestones/dashboard-refresh.md"
)
milestone.parent.mkdir(parents=True)
milestone.write_text("# Milestone: Dashboard refresh\n")
prepared = sync_state._prepare_milestone(
self.prepare_milestone_args(digest)
)
state = json.loads(self.state_path.read_text())
self.assertEqual("prepared", prepared["status"])
self.assertEqual(1, len(state["pending_milestone_work"]))
with self.assertRaises(sync_state.StateError):
sync_state._prepare(
self.prepare_args(
prepared["after_sha256"],
frame_path=[],
)
)
def test_reconcile_milestone_moves_mapping_and_is_idempotent(self):
digest = write_json(self.state_path, self.base_state)
milestone = self.root / (
"agent-roadmap/phase/product/milestones/dashboard-refresh.md"
)
milestone.parent.mkdir(parents=True)
milestone.write_text("# Milestone: Dashboard refresh\n")
prepared = sync_state._prepare_milestone(
self.prepare_milestone_args(digest)
)
reconciled = sync_state._reconcile_milestone(
self.reconcile_milestone_args(prepared["after_sha256"])
)
state = json.loads(self.state_path.read_text())
self.assertEqual("reconciled", reconciled["status"])
self.assertEqual([], state["pending_milestone_work"])
self.assertEqual(1, len(state["reconciled_milestone_work"]))
self.assertEqual("a" * 40, state["last_synced_head"])
milestone.unlink()
repeated = sync_state._reconcile_milestone(
self.reconcile_milestone_args(reconciled["after_sha256"])
)
self.assertEqual("already_reconciled", repeated["status"])
def test_v2_legacy_pending_paths_are_typed_during_migration(self):
state = dict(self.base_state)
state["schema_version"] = 2
state["pending_code_work"] = [
{
"task_path": "agent-task/ui_work",
"scope": "all",
"agent_ui_paths": [
"agent-ui/definition/views/dashboard/index.md",
"agent-ui/frame/views/dashboard/index.md",
],
"code_paths": ["src/dashboard.py"],
"verification_requirements": [],
"prepared_at": "2026-07-29T00:00:00Z",
"state": "pending",
}
]
normalized = sync_state._normalize_state(state)
entry = normalized["pending_code_work"][0]
self.assertEqual(
["agent-ui/definition/views/dashboard/index.md"],
entry["status_paths"],
)
self.assertEqual(
["agent-ui/frame/views/dashboard/index.md"],
entry["frame_paths"],
)
def test_normalize_rejects_non_list_and_duplicate_pending_entries(self):
invalid = dict(self.base_state)
invalid["pending_code_work"] = {}
with self.assertRaises(sync_state.StateError):
sync_state._normalize_state(invalid)
entry = {
"task_path": "agent-task/ui_work",
"scope": "all",
"status_paths": ["agent-ui/definition/views/dashboard/index.md"],
"frame_paths": [],
"code_paths": ["src/dashboard.py"],
"verification_requirements": [],
"prepared_at": "2026-07-29T00:00:00Z",
"state": "pending",
}
duplicate = dict(self.base_state)
duplicate["schema_version"] = 3
duplicate["pending_code_work"] = [entry, dict(entry)]
duplicate["reconciled_code_work"] = []
with self.assertRaises(sync_state.StateError):
sync_state._normalize_state(duplicate)
second_owner = dict(entry)
second_owner["task_path"] = "agent-task/other"
duplicate_owner = dict(self.base_state)
duplicate_owner["schema_version"] = 3
duplicate_owner["pending_code_work"] = [entry, second_owner]
duplicate_owner["reconciled_code_work"] = []
with self.assertRaises(sync_state.StateError):
sync_state._normalize_state(duplicate_owner)
def test_reconcile_moves_pending_entry_and_is_idempotent(self):
digest = write_json(self.state_path, self.base_state)
prepared = sync_state._prepare(self.prepare_args(digest))
completion_log = Path("agent-task/archive/2026/07/ui_work/complete.log")
(self.root / completion_log).parent.mkdir(parents=True)
(self.root / completion_log).write_text(
"# Complete - ui_work\n\n"
"## 루프 이력\n\n"
"| Plan | Review | Verdict | 메모 |\n"
"|---|---|---|---|\n"
"| `plan.log` | `review.log` | PASS | done |\n"
)
reconciled = sync_state._reconcile(
self.reconcile_args(prepared["after_sha256"], self.root / completion_log)
)
state = json.loads(self.state_path.read_text())
self.assertEqual("reconciled", reconciled["status"])
self.assertEqual([], state["pending_code_work"])
self.assertEqual(1, len(state["reconciled_code_work"]))
self.assertEqual("a" * 40, state["last_synced_head"])
self.assertIn(
"baseline=preserved:no validated sync_result_head",
state["notes"][-1],
)
repeated = sync_state._reconcile(
self.reconcile_args(reconciled["after_sha256"], self.root / completion_log)
)
self.assertEqual("already_reconciled", repeated["status"])
def test_reconcile_rejects_completion_log_for_another_task(self):
digest = write_json(self.state_path, self.base_state)
prepared = sync_state._prepare(self.prepare_args(digest))
completion_log = Path("agent-task/archive/2026/07/other/complete.log")
(self.root / completion_log).parent.mkdir(parents=True)
(self.root / completion_log).write_text(
"# Complete - other\n\n"
"## 루프 이력\n\n"
"| Plan | Review | Verdict | 메모 |\n"
"|---|---|---|---|\n"
"| `plan.log` | `review.log` | PASS | done |\n"
)
with self.assertRaises(sync_state.StateError):
sync_state._reconcile(
self.reconcile_args(prepared["after_sha256"], completion_log)
)
def test_record_sync_preserves_pending_and_reconciled_state(self):
digest = write_json(self.state_path, self.base_state)
prepared = sync_state._prepare(self.prepare_args(digest))
recorded = sync_state._record_sync(
self.record_sync_args(prepared["after_sha256"])
)
state = json.loads(self.state_path.read_text())
self.assertEqual("sync_recorded", recorded["status"])
self.assertEqual("incremental", state["last_sync_mode"])
self.assertEqual("b" * 40, state["last_synced_head"])
self.assertEqual(1, len(state["pending_code_work"]))
self.assertEqual([], state["reconciled_code_work"])
def test_record_sync_preserves_pending_milestone_state(self):
digest = write_json(self.state_path, self.base_state)
milestone = self.root / (
"agent-roadmap/phase/product/milestones/dashboard-refresh.md"
)
milestone.parent.mkdir(parents=True)
milestone.write_text("# Milestone: Dashboard refresh\n")
prepared = sync_state._prepare_milestone(
self.prepare_milestone_args(digest)
)
recorded = sync_state._record_sync(
self.record_sync_args(prepared["after_sha256"])
)
state = json.loads(self.state_path.read_text())
self.assertEqual("sync_recorded", recorded["status"])
self.assertEqual(1, len(state["pending_milestone_work"]))
self.assertEqual([], state["reconciled_milestone_work"])
def test_record_migration_allows_no_code_paths(self):
digest = write_json(self.state_path, self.base_state)
recorded = sync_state._record_sync(
self.record_sync_args(
digest,
sync_mode="migration",
code_path=[],
)
)
state = json.loads(self.state_path.read_text())
self.assertEqual("sync_recorded", recorded["status"])
self.assertEqual("migration", state["last_sync_mode"])
self.assertEqual([], state["code_paths"])
if __name__ == "__main__":
unittest.main()

View file

@ -1,7 +1,6 @@
---
name: update-roadmap
version: 1.24.2
description: 로드맵 업데이트, 로드맵에 추가, 마일스톤 추가/갱신, phase/페이즈 변경, 전역 Milestone 실행 순서 갱신 요청에 사용한다. Roadmap-priority-queue-Phase-Milestone scaffold에서 target 없는 신규 작업의 규모를 판정하고 기존 Phase/Milestone/Epic/Task를 검색해 upsert한 뒤, 없을 때만 새 항목을 만들고 priority-queue.md 순서 동기화, 로컬 current.md 동기화, runtime m-task 완료 이벤트 반영, 완료 후보 검토중 전환, agent-ui 코드 동기화 Milestone의 종료 검토 시 구현됨 상태 반영, 완료 근거 충족 archive 이동, workspace 외부 의존 잠금 양방향 동기화를 처리한다.
description: 로드맵 업데이트, 로드맵에 추가, 마일스톤 추가·갱신, phase 변경, 전역 Milestone 실행 순서 갱신 요청에 사용한다. 기존 항목 upsert, runtime 완료 반영, 완료 후보 전환과 archive, workspace 외부 의존 잠금 동기화를 처리한다.
---
# 로드맵 업데이트
@ -219,21 +218,6 @@ agent-roadmap/
- 보류 또는 폐기 근거가 있으면 `완료 리뷰`와 Milestone 상태를 각각 `보류`/`[보류]`, `폐기`/`[폐기]`로 맞춘다. `[폐기]`는 archive 대상이 될 수 있다.
- Phase는 하위 Milestone이 모두 `[완료]` 또는 `[폐기]`로 정리되고 Phase 목표도 충족된 것으로 보일 때 `[완료]` 또는 `[폐기]`로 archive한다.
### agent-ui 코드 동기화 Milestone 완료 리뷰
- 이 절은 `sync-agent-ui`가 코드 작업을 `milestone-required`로 라우팅했거나 사용자가 명시해 Milestone `완료 리뷰``agent-ui 상태 반영: 대기`가 있는 경우에만 적용한다.
- agent-ui 문서만 갱신한 Milestone, 작은 `direct-sync` 작업, 일반 `plan-required` 작업, `agent-ui 상태 반영: 해당 없음`인 Milestone에는 적용하지 않는다.
- 기존 Milestone에 `agent-ui 상태 반영` 항목이 없으면 `해당 없음`으로 취급한다. 문서 구조 보정 또는 해당 Milestone 갱신 범위에 포함될 때만 `agent-ui 상태 반영: 해당 없음`을 보강한다.
- 사용자가 "이 Milestone을 종료해도 될지 검토", "마일스톤 완료 검토", "종료 검토"처럼 완료 리뷰를 요청했거나 `review-state=통과`로 갱신할 때 적용한다.
- agent-ui 코드 동기화 Milestone이 완료 리뷰 `통과` 조건을 충족하면 `[완료]` 전환 또는 archive 전에 관련 agent-ui view/component/frame 문서의 `status``구현됨`으로 반영할 수 있다.
- 상태 반영은 완료 evidence가 직접 가리키는 agent-ui 활성 문서에만 적용한다. Milestone 전체 완료만을 근거로 `agent-ui/definition/**` 전체를 일괄 `구현됨`으로 바꾸지 않는다.
- 각 대상 문서는 실제 존재하는 code evidence path와 최종 검증 근거가 있어야 `구현됨`으로 바꾼다. 근거가 없으면 `계획` 또는 기존 상태를 유지하고 완료 리뷰를 `보완 필요`로 둔다.
- agent-ui 상태 반영은 `update-agent-ui`의 문서 갱신 규칙을 따르고, 반영 후 `validate-agent-ui`로 정합성을 확인한다. 이 단계는 이미 완료된 코드 반영의 문서 상태 갱신이므로 `sync-agent-ui`를 새로 실행하지 않는다.
- status/code evidence 반영과 `validate-agent-ui`가 모두 통과하면 Milestone `완료 리뷰``agent-ui 상태 반영``완료`로 바꾼다.
- 근거 부족, 미해결 USER_REVIEW, 또는 `validate-agent-ui` 실패로 반영하지 못하면 Milestone `완료 리뷰``agent-ui 상태 반영``차단: <사유>`로 바꾸고 `상태: 보완 필요`를 남긴다.
- `validate-agent-ui`가 FAIL이거나 현재 범위에 미해결 `agent-ui/USER_REVIEW.md`가 있으면 Milestone을 `[완료]`로 전환하지 않고 완료 리뷰를 `보완 필요`로 둔다.
- `agent-ui 상태 반영: 대기`가 아닌 Milestone에서는 완료 리뷰 중 agent-ui status를 변경하지 않는다.
## Milestone task group 연동
- 런타임 완료 이벤트의 `origin-task`에서 `agent-task/` 다음 첫 path segment가 `m-<milestone-slug>`이면 Milestone 기반 plan/review 완료에서 온 요청으로 본다. `origin-task`는 archive 이동 전 active task 경로 또는 런타임이 그 형태로 정규화한 경로를 사용한다.
@ -346,7 +330,6 @@ target 없는 신규 추가 요청은 append가 아니라 upsert로 처리한다
- 구조 전환, 템플릿 보정, current 동기화는 `sync`로 본다.
- `priority-queue.md` 생성, 순서 조정, 깨진 링크 복구, archive/폐기/경로 변경/split/merge 후 큐 정리는 `sync` 또는 `replan`으로 본다.
- 완료/폐기 근거가 충족된 이동은 `archive`로 본다.
- 완료 리뷰 요청에서 대상 Milestone의 `완료 리뷰``agent-ui 상태 반영: 대기`인지 확인한다. 항목이 없거나 `대기`가 아니면 agent-ui 상태 반영 단계를 적용하지 않는다.
- 새 기능 배치, Epic/Task 추가는 `milestone` 또는 `phase`로 본다.
- "로드맵에 추가"처럼 target이 없는 신규 작업 요청은 `placement=auto`, `placement-unit=auto`, `new-feature=<요청 내용>`으로 본다.
- 외부 의존 잠금 요청이면 `workspace-lock` 갱신으로 본다.
@ -401,8 +384,6 @@ target 없는 신규 추가 요청은 append가 아니라 upsert로 처리한다
- 모든 기능 Task와 Task 안에 명시된 검증이 evidence와 함께 `[x]`이어도 `구현 잠금``해제`가 아니거나 미완료 `결정 필요` 항목이 있으면 Milestone 상태를 `[검토중]`으로 바꾸지 않는다. `완료 리뷰` 또는 `작업 컨텍스트`에 잠금 차단 항목을 남긴다.
- 모든 기능 Task와 Task 안에 명시된 검증이 evidence와 함께 `[x]`이고 `구현 잠금`도 해제되어 있으면 Milestone 상태를 `[검토중]`으로 바꾸고 `완료 리뷰`에 완료 근거와 남은 차단 항목을 남긴다.
- `[검토중]` 전환만으로 archive 이동, 로컬 `current.md` 제거, archive 링크 변경을 수행하지 않는다.
- `agent-ui 상태 반영: 대기`인 Milestone의 완료 리뷰가 `통과` 후보이면 `[완료]` 전환 전에 관련 agent-ui 문서의 `status``구현됨`으로 갱신할 수 있는지 확인한다. 대상 문서, code evidence, 최종 검증 근거, `validate-agent-ui` 결과가 모두 충족될 때만 반영하고 `agent-ui 상태 반영: 완료`로 바꾼다.
- agent-ui 상태 반영이 필요한데 근거가 부족하거나 `validate-agent-ui`가 통과하지 않으면 Milestone 상태를 `[진행중]` 또는 `[검토중]`으로 유지하고 `완료 리뷰: 보완 필요``agent-ui 상태 반영: 차단: <사유>`를 남긴다.
- 기능 Task, 검증, 구현 잠금, 완료 근거가 모두 충족되면 `[검토중]``[완료]`로 전환하고 archive 모드를 수행할 수 있다.
- `locks.yaml`이 있으면 갱신 대상 Milestone identity로 `--find-milestone "<identity>" both "<locks-file>"`를 실행해 이 Milestone이 `locked`인지, `rely-on.target`인지, 관련 lock이 없는지 확인한다.
- archive 모드이면 파일 이동 전 active Milestone identity를 보존하고, 그 identity로 `--find-milestone "<identity>" both "<locks-file>"`를 먼저 실행한다.
@ -434,8 +415,6 @@ target 없는 신규 추가 요청은 append가 아니라 upsert로 처리한다
- `[검토중]` Milestone이 archive 경로로 이동되지 않았는지 확인한다.
- 모든 기능 Task와 Task 안에 명시된 검증이 `[x]`인 Milestone은 `구현 잠금`이 해제되어야 `[검토중]`이 될 수 있다. 잠금이 남아 있으면 `완료 리뷰` 또는 `작업 컨텍스트`에 잠금 차단 항목이 있는지 확인한다.
- `[검토중]` Milestone에는 `완료 리뷰` 섹션과 완료 근거/남은 차단 항목이 있는지 확인한다.
- `agent-ui 상태 반영: 대기`인 Milestone을 `[완료]`로 전환하는 경우, 완료 evidence가 가리키는 agent-ui 문서의 status/code evidence 반영 여부와 `validate-agent-ui` 결과가 완료 리뷰에 남았는지 확인한다.
- `agent-ui 상태 반영: 대기`가 아닌 Milestone 완료 리뷰에서 agent-ui 문서 status를 변경하지 않았는지 확인한다.
- 각 Milestone의 `구현 잠금`에 SDD 필요 여부와 사유가 있는지 확인한다.
- `SDD: 필요` Milestone은 SDD 문서 링크, SDD 파일 존재, 잠금 해제 조건, SDD 사용자 리뷰 상태가 일관되는지 확인한다. 사용자가 명시적으로 SDD 생성을 뒤로 미루지 않았는데 SDD 파일이 없으면 검증 실패로 본다.
- Epic heading과 Task id 형식이 맞고, 새 Epic과 갱신 범위에 포함된 기존 Epic의 기능 Task 수가 최대 5개인지 확인한다. 6개 이상이면 검증 실패로 보고하고 Epic 분리를 요구한다.
@ -462,7 +441,6 @@ target 없는 신규 추가 요청은 append가 아니라 upsert로 처리한다
- 전역 실행 순서 변경 사항
- 로컬 current.md 활성 창 변경 사항
- 완료 리뷰 상태와 남은 차단 항목
- `agent-ui 상태 반영: 대기`인 Milestone이면 agent-ui status 반영 여부와 validate-agent-ui 결과
- SDD gate 상태와 사용자 리뷰 필요 여부
- 런타임 완료 이벤트의 `origin-task``m-<milestone-slug>`이면 원래 active task 경로와 매칭된 target Milestone, `Roadmap Completion` Task ids 또는 no-op 사유
- archive 모드이면 이동 경로와 남긴 링크
@ -474,21 +452,11 @@ target 없는 신규 추가 요청은 append가 아니라 upsert로 처리한다
## 업데이트 완료
- 모드: <status | milestone | phase | replan | sync | concretize | archive>
- 수정 파일:
- [ROADMAP.md](agent-roadmap/ROADMAP.md)
- [priority-queue.md](agent-roadmap/priority-queue.md)
- [current.md](agent-roadmap/current.md) (local)
- [PHASE.md](agent-roadmap/phase/<phase-slug>/PHASE.md)
- [<milestone-slug>.md](agent-roadmap/phase/<phase-slug>/milestones/<milestone-slug>.md)
- [SDD.md](agent-roadmap/sdd/<phase-slug>/<milestone-slug>/SDD.md) (SDD 작성/갱신 시)
- [USER_REVIEW.md](agent-roadmap/sdd/<phase-slug>/<milestone-slug>/USER_REVIEW.md) (SDD 사용자 리뷰 요청 시)
- [archive PHASE.md](agent-roadmap/archive/phase/<phase-slug>/PHASE.md) 또는 [archive Milestone](agent-roadmap/archive/phase/<phase-slug>/milestones/<milestone-slug>.md) (archive 모드)
- [archive SDD.md](agent-roadmap/archive/sdd/<phase-slug>/<milestone-slug>/SDD.md) (SDD archive 시)
- 수정 파일: <실제로 변경한 ROADMAP/queue/current/Phase/Milestone/SDD/archive Markdown 링크 목록>
## 변경 사항
- Phase: <변경 없음 | 요약>
- Milestone: <변경 없음 | 요약>
- Phase / Milestone: <변경 없음 | 요약>
- 삽입 단위: <Phase | Milestone | Epic | Task | 하위 작업 | 작업 컨텍스트/TODO | 변경 없음>
- 규모 판정: <phase | milestone | epic | task | subtask | context | 변경 없음> - <근거>
- 탐색 경로: <Phase 후보 -> Milestone 후보 -> Epic 후보 -> Task 후보 | 해당 없음>
@ -501,7 +469,6 @@ target 없는 신규 추가 요청은 append가 아니라 upsert로 처리한다
- SDD gate: <불필요 | 필요-작성 | 필요-잠금 | 필요-사용자 리뷰 | 필요-승인됨 | 변경 없음>
- 승격 조건: <해당 없음 | 추가/수정/미충족 유지/충족 요약>
- 완료 리뷰: <변경 없음 | 검토중 | 통과 | 보완 필요 | 보류 | 폐기>
- agent-ui 상태 반영: <해당 없음 | 대기 | 완료 | 차단: 사유 | 변경 없음>
- runtime m-task 라우팅: <해당 없음 | origin-task -> target Milestone | target 불명확>
- Workspace 잠금: <변경 없음 | 관련 lock 없음 | entry 생성/갱신 | rely-on enable | rely-on disable | 미충족 | 런타임 해제 대기>
- 활성 항목: <변경 없음 | Phase/Milestone 추가/제거 요약>
@ -532,8 +499,6 @@ target 없는 신규 추가 요청은 append가 아니라 upsert로 처리한다
- Phase와 Milestone 이름 또는 파일명에 순번을 강제하지 않는다.
- 에이전트가 확정할 수 없는 결정 항목이 남아 있는데 Milestone의 `구현 잠금``해제`로 바꾸지 않는다.
- `구현 잠금`이 남아 있는 Milestone을 `[검토중]`, `[완료]`, 또는 완료 archive 대상으로 전환하지 않는다. 명시적인 폐기 근거가 있는 `[폐기]` archive는 허용한다.
- `agent-ui 상태 반영: 대기`가 아닌 Milestone 완료 리뷰에서 agent-ui 문서 status를 변경하지 않는다.
- `agent-ui 상태 반영: 대기`인 Milestone이라도 최종 검증과 실제 code evidence 없이 agent-ui status를 `구현됨`으로 바꾸지 않는다.
- 사용자가 지정한 Phase/Milestone/Epic/Task anchor를 무시하지 않는다.
- 사용자가 명시하지 않은 기존 epic-id나 item-id를 바꾸지 않는다.
- `rely-on.status=enable`만으로 다른 프로젝트 Milestone의 `구현 잠금`을 직접 해제하지 않는다.

View file

@ -1,7 +1,6 @@
---
name: update-test
version: 1.2.0
description: 기존 agent-test 환경 rules.md 또는 테스트 profile을 수정하고, plan 같은 소비자에게 파일 수정 없는 Verification Context를 제공하는 스킬
description: 기존 agent-test 환경 rules.md 또는 테스트 profile을 수정하고 파일 수정 없는 Verification Context를 제공한다. 테스트 규칙 수정·갱신, 테스트 컨텍스트 해석, Verification Context 생성 요청에 사용한다.
---
# update-test
@ -98,6 +97,7 @@ description: 기존 agent-test 환경 rules.md 또는 테스트 profile을 수
- env rules와 matching profile에서 실행 위치, 명령, 필수 순서, 기대 결과, 차단 기준, cache 허용 여부, 외부 서비스/secret 요구 여부를 원문 의미를 바꾸지 않고 추출한다.
- 명령이 여러 profile에 걸치면 profile별 출처와 적용 범위를 유지한다.
- `<확인 필요>` 또는 서로 충돌하는 값은 확정하지 않고 gap으로 기록한다.
- env rules와 matching profile이 usable이고 적용 명령·판정 기준에 gap이 없으면 confidence를 `high`, 일부 값에 repository-native 확인이 더 필요하면 `medium`, rules가 missing/blank이거나 핵심 값이 unresolved이면 `low`로 판정한다.
4. **외부 환경 preflight 구성**
- 현재 checkout 밖의 runner, field/bootstrap, external provider, Docker/code-server, emulator/device, shared runtime이 필요한 검증만 read-only preflight 대상으로 삼는다.
@ -156,6 +156,7 @@ description: 기존 agent-test 환경 rules.md 또는 테스트 profile을 수
- <금지 사항, 외부 서비스/secret 요구 여부 또는 없음>
- Gaps:
- <missing profile, blank value, `<확인 필요>`, conflict 또는 없음>
- Confidence: <high|medium|low>; <판정 근거>
- Maintenance: <not-needed|create-test-candidate|update-test-candidate>; <근거>
```