modify harness framework
This commit is contained in:
@@ -5,168 +5,124 @@ description: Use when planning agentic implementation phases, creating phases/in
|
||||
|
||||
# Harness Workflow
|
||||
|
||||
이 프로젝트는 Harness 프레임워크를 사용한다. 아래 워크플로에 따라 작업한다.
|
||||
Harness는 MSVC C++ 제품 개발을 위한 계획·실행 도구다. 제품 요구사항과 기술 결정을
|
||||
Harness 자체 요구사항과 섞지 않는다.
|
||||
|
||||
## A. 탐색
|
||||
## 탐색과 논의
|
||||
|
||||
`AGENTS.md`와 `docs/` 하위 문서(PRD, ARCHITECTURE, ADR 등)를 읽고 프로젝트의 기획,
|
||||
아키텍처, 설계 의도를 파악한다. 병렬 탐색이 실제로 유용하고 현재 세션에서 허용될
|
||||
때만 Codex subagent를 선택적으로 사용한다.
|
||||
1. `AGENTS.md`를 읽는다.
|
||||
2. 변경 범위와 직접 관련된 제품 문서, 코드, 테스트와 `.harness/config.json`만 읽는다.
|
||||
3. 미정인 제품 요구사항이나 공개 계약이 구현 결과를 바꾸면 사용자와 논의한다.
|
||||
|
||||
## B. 논의
|
||||
전체 `docs/`를 일괄 읽거나 그 본문을 step에 복사하지 않는다. 이전 대화 없이도 실행할
|
||||
수 있도록 필요한 기존 파일을 명시적으로 참조한다.
|
||||
|
||||
구현을 위해 구체화하거나 기술적으로 결정해야 할 사항이 있으면 사용자에게 한 번에
|
||||
하나씩 제시하고 논의한다.
|
||||
## Step 설계
|
||||
|
||||
## C. Step 설계
|
||||
하나의 step에는 다음 요소만 필수로 둔다.
|
||||
|
||||
사용자가 구현 계획 작성을 지시하면 여러 step으로 나뉜 초안을 작성해 피드백을
|
||||
요청한다.
|
||||
- 목표와 범위
|
||||
- 관련 파일과 `context_files`
|
||||
- 핵심 제약
|
||||
- 실행 가능한 Acceptance Criteria
|
||||
|
||||
설계 원칙:
|
||||
공개 API나 ABI 계약이 중요한 경우에만 시그니처를 적는다. 새 기능과 버그 수정은 한
|
||||
step 안에서 실패 테스트 확인, 최소 구현, 성공 확인까지 끝낸다. 실제 설정과 기존
|
||||
테스트 명령을 우선하며 확인되지 않은 예시 명령을 AC로 쓰지 않는다.
|
||||
|
||||
1. **Scope 최소화** — 하나의 step에서 하나의 레이어 또는 모듈만 다룬다. 여러
|
||||
모듈을 동시에 수정해야 하면 step을 쪼갠다.
|
||||
2. **자기완결성** — 각 step 파일은 독립된 Codex 실행에서 사용된다. 외부 대화
|
||||
참조를 금지하고 필요한 정보를 모두 파일 안에 적는다.
|
||||
3. **사전 준비 강제** — 관련 문서와 이전 step에서 생성하거나 수정한 파일 경로를
|
||||
명시한다.
|
||||
4. **시그니처 수준 지시** — 함수와 클래스의 인터페이스를 제시하고 내부 구현은
|
||||
Codex 재량에 맡긴다. 멱등성, 보안, 데이터 무결성 같은 핵심 규칙은 명시한다.
|
||||
5. **AC는 실행 가능한 command** — 추상적 조건 대신 실제 빌드와 테스트 command를
|
||||
포함한다.
|
||||
6. **주의사항은 구체적으로** — "X를 하지 마라. 이유: Y" 형식으로 적는다.
|
||||
7. **네이밍** — step name은 핵심 작업을 표현하는 kebab-case slug로 정한다.
|
||||
## Index 형식
|
||||
|
||||
## D. 파일 생성
|
||||
|
||||
사용자가 초안을 승인한 후에만 다음 파일을 생성한다.
|
||||
|
||||
### D-1. `phases/index.json`
|
||||
|
||||
여러 task를 관리하는 top-level 인덱스다. 이미 존재하면 `phases` 배열에 새 항목을
|
||||
추가한다.
|
||||
`phases/index.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"phases": [
|
||||
"phases": [{"dir": "add-parser", "status": "pending"}]
|
||||
}
|
||||
```
|
||||
|
||||
`phases/{phase}/index.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"project": "<제품명 또는 미정>",
|
||||
"phase": "add-parser",
|
||||
"steps": [
|
||||
{
|
||||
"dir": "0-mvp",
|
||||
"status": "pending"
|
||||
"step": 0,
|
||||
"name": "implement-parser",
|
||||
"status": "pending",
|
||||
"context_files": ["docs/ARCHITECTURE.md"],
|
||||
"validation": "full"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `dir`: task 디렉터리명
|
||||
- `status`: `pending` | `completed` | `error` | `blocked`
|
||||
- timestamp는 executor가 상태를 바꿀 때 기록하므로 생성 시 넣지 않는다.
|
||||
- `name`은 kebab-case slug다.
|
||||
- `context_files`는 선택 사항인 저장소 상대 파일 경로 배열이다. glob과 URL은 쓰지
|
||||
않는다. 생략하거나 빈 배열이면 추가 문서를 주입하지 않는다.
|
||||
- `validation`은 `full` 또는 `docs-only`이며 생략 시 `full`이다. `docs-only`는 실제
|
||||
변경이 허용된 문서 경로에만 있을 때만 제품 검증 생략을 요청한다.
|
||||
- 생성 시 status는 `pending`이다. timestamp는 executor가 기록한다.
|
||||
|
||||
### D-2. `phases/{task-name}/index.json`
|
||||
상태 기록:
|
||||
|
||||
```json
|
||||
{
|
||||
"project": "<프로젝트명>",
|
||||
"phase": "<task-name>",
|
||||
"steps": [
|
||||
{ "step": 0, "name": "project-setup", "status": "pending" },
|
||||
{ "step": 1, "name": "core-types", "status": "pending" },
|
||||
{ "step": 2, "name": "api-layer", "status": "pending" }
|
||||
]
|
||||
}
|
||||
```
|
||||
| 상태 | Codex 기록 | Executor 기록 |
|
||||
|---|---|---|
|
||||
| `completed` | 한 줄 `summary` | `completed_at` |
|
||||
| `error` | `error_message` | `failed_at` |
|
||||
| `blocked` | `blocked_reason` | `blocked_at` |
|
||||
|
||||
필드 규칙:
|
||||
|
||||
- `project`: `AGENTS.md`에 정의된 프로젝트명
|
||||
- `phase`: task 이름이며 디렉터리명과 일치
|
||||
- `steps[].step`: 0부터 시작하는 순번
|
||||
- `steps[].name`: kebab-case slug
|
||||
- `steps[].status`: 초기값 `pending`
|
||||
|
||||
상태와 기록 주체:
|
||||
|
||||
| 전이 | 기록 필드 | 기록 주체 |
|
||||
|------|-----------|-----------|
|
||||
| `completed` | `summary`, `completed_at` | Codex가 summary, executor가 timestamp |
|
||||
| `error` | `error_message`, `failed_at` | Codex가 message, executor가 timestamp |
|
||||
| `blocked` | `blocked_reason`, `blocked_at` | Codex가 reason, executor가 timestamp |
|
||||
|
||||
`summary`에는 다음 step에 유용한 생성 파일과 핵심 결정을 한 줄로 적는다.
|
||||
task `created_at`과 step `started_at`은 executor가 기록하므로 생성 시 넣지 않는다.
|
||||
|
||||
### D-3. `phases/{task-name}/step{N}.md`
|
||||
## Step 파일
|
||||
|
||||
````markdown
|
||||
# Step {N}: {이름}
|
||||
# Step N: <name>
|
||||
|
||||
## 읽어야 할 파일
|
||||
## 목표와 범위
|
||||
<한 가지 책임과 범위 밖 항목>
|
||||
|
||||
먼저 아래 파일을 읽고 프로젝트의 아키텍처와 설계 의도를 파악하라:
|
||||
## 관련 파일
|
||||
- `<path>`
|
||||
|
||||
- `/AGENTS.md`
|
||||
- `/docs/ARCHITECTURE.md`
|
||||
- `/docs/ADR.md`
|
||||
- 이전 step에서 생성하거나 수정한 파일 경로
|
||||
|
||||
이전 step의 코드를 꼼꼼히 읽고 설계 의도를 이해한 뒤 작업하라.
|
||||
|
||||
## 작업
|
||||
|
||||
구체적인 구현 지시를 파일 경로, 클래스와 함수 시그니처, 로직 설명과 함께 적는다.
|
||||
구현체는 Codex에 맡기되 설계 의도에서 벗어나면 안 되는 핵심 규칙은 명시한다.
|
||||
## 핵심 제약
|
||||
- <유지할 계약과 이유>
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
프로젝트 형식에 맞는 명령을 사용한다. `.harness/config.json`이 있으면 해당 preset,
|
||||
solution, configuration, platform, test command를 우선한다.
|
||||
|
||||
```powershell
|
||||
# CMake
|
||||
cmake --build .harness/build --config Debug
|
||||
ctest --test-dir .harness/build -C Debug --output-on-failure
|
||||
|
||||
# 직접 MSBuild
|
||||
MSBuild.exe MyProject.sln /m /p:Configuration=Debug /p:Platform=x64
|
||||
.\build\tests\Debug\MyProjectTests.exe
|
||||
<실제 build/test 명령>
|
||||
```
|
||||
|
||||
## 검증 절차
|
||||
|
||||
1. Acceptance Criteria command를 실행한다.
|
||||
2. ARCHITECTURE 디렉터리 구조를 따르는지 확인한다.
|
||||
3. ADR 기술 스택과 `AGENTS.md` CRITICAL 규칙을 확인한다.
|
||||
4. 결과에 따라 task index의 해당 step을 갱신한다.
|
||||
- 성공: `status`를 `completed`로 바꾸고 한 줄 `summary` 기록
|
||||
- 수정 3회 후 실패: `status`를 `error`로 바꾸고 `error_message` 기록
|
||||
- 사용자 개입 필요: `status`를 `blocked`로 바꾸고 `blocked_reason` 기록 후 중단
|
||||
|
||||
## 금지사항
|
||||
|
||||
- 이 step의 범위 밖 기능을 추가하지 마라. 이유: step의 독립성을 깨뜨린다.
|
||||
- 기존 테스트를 깨뜨리지 마라. 이유: 이전 동작을 회귀시킨다.
|
||||
````
|
||||
|
||||
## E. 실행
|
||||
현재 step보다 앞선 작업이 만든 파일은 관련 파일에 적고 실행 중 직접 읽는다. 이미
|
||||
존재하는 제품 문서만 `context_files`에 넣는다.
|
||||
|
||||
```bash
|
||||
python scripts/execute.py {task-name}
|
||||
python scripts/execute.py {task-name} --push
|
||||
## Phase 기록
|
||||
|
||||
`docs/phase/{phase}/handoff.md`는 최신 상태, 결정, 미해결 문제, 마지막 검증과 다음
|
||||
시작점을 약 60줄 이내로 유지한다. `stepN.md`에는 변경 파일, 결정, 검증 결과와 유효한
|
||||
시행착오를 기록한다. 성공·실패·차단을 보고하기 전에 둘을 갱신한다. 형식은
|
||||
`docs/phase/README.md`를 따른다.
|
||||
|
||||
## 실행
|
||||
|
||||
```powershell
|
||||
python scripts/execute.py <phase>
|
||||
python scripts/execute.py <phase> --dry-run
|
||||
python scripts/execute.py <phase> --push
|
||||
```
|
||||
|
||||
환경에서 Python 3 실행 명령이 `python3`이면 그 명령을 대신 사용한다.
|
||||
`--dry-run`은 첫 pending step의 주입 경로와 문자 수, 전체 프롬프트 문자 수만
|
||||
출력하며 파일, Git, 상태와 Codex를 변경하지 않는다. `--push`와 함께 쓸 수 없다.
|
||||
|
||||
executor가 처리하는 작업:
|
||||
프롬프트는 `AGENTS → 공통 규칙 → context_files → 최신 handoff 또는 호환 summary →
|
||||
직전 실패 → 현재 step` 순서다. handoff가 없을 때만 현재 step보다 앞선 완료 step 중
|
||||
가장 최근 하나의 summary를 최대 1,000자로 전달한다.
|
||||
|
||||
- `feat-{task-name}` 브랜치 생성 또는 checkout
|
||||
- `AGENTS.md`와 `docs/*.md` guardrail 주입
|
||||
- 완료 step의 summary를 다음 prompt에 누적
|
||||
- 실패 시 최대 3회 재시도하며 이전 오류를 prompt에 전달
|
||||
- 코드 변경과 metadata를 분리해 commit
|
||||
- `started_at`, `completed_at`, `failed_at`, `blocked_at` 기록
|
||||
Executor는 최초 호출을 포함해 Codex를 최대 3회 호출한다. 재시도 오류 본문은 총
|
||||
4,000자 안에서 앞 1,500자, 중간 생략 marker와 끝부분 최대 2,500자를 보존한다. 원본 출력은
|
||||
`phases/{phase}/stepN-output.json`에 온전히 쓰지만 다음 호출에서 덮어쓴다. 지속할
|
||||
시행착오는 `docs/phase/{phase}/stepN.md`에 남긴다.
|
||||
|
||||
에러 복구:
|
||||
|
||||
- `error`: 해당 status를 `pending`으로 바꾸고 `error_message`를 삭제한 뒤 재실행
|
||||
- `blocked`: 원인을 해결하고 status를 `pending`으로 바꾸고 `blocked_reason`을 삭제한
|
||||
뒤 재실행
|
||||
`completed`는 Codex 프로세스 exit code가 0일 때만 성공이다. `blocked`는 즉시 멈춘다.
|
||||
`error`나 `blocked`를 재개하려면 원인을 해결하고 status를 `pending`으로 바꾼 뒤 해당
|
||||
오류 필드와 timestamp를 제거한다.
|
||||
|
||||
@@ -5,37 +5,25 @@ description: Use when reviewing repository changes against AGENTS.md, architectu
|
||||
|
||||
# Repository Review
|
||||
|
||||
먼저 다음 문서를 읽는다.
|
||||
`AGENTS.md`를 먼저 읽고 변경 범위와 직접 관련된 제품 문서, 코드와 테스트만 추가로
|
||||
읽는다. `context_files`가 있으면 해당 목록을 우선한다. 전체 `docs/`를 일괄 읽도록
|
||||
요구하지 않는다.
|
||||
|
||||
- `/AGENTS.md`
|
||||
- `/docs/ARCHITECTURE.md`
|
||||
- `/docs/ADR.md`
|
||||
사용자가 범위를 지정하지 않으면 현재 작업 트리의 변경을 리뷰한다. 실제 결함을
|
||||
심각도순으로 제시하고 각 finding에 파일과 줄 번호, 영향, 근거와 수정 방안을 적는다.
|
||||
|
||||
사용자가 범위를 지정하지 않으면 현재 작업 트리의 변경을 리뷰한다. 관련 diff를
|
||||
확인하고 가능한 빌드와 테스트 command를 실제로 실행한다.
|
||||
다음을 해당 변경에 적용할 수 있는 범위에서 확인한다.
|
||||
|
||||
## 체크리스트
|
||||
- public header와 implementation의 의존성 방향 및 관련 ADR
|
||||
- 새 C/C++ 동작을 먼저 재현하는 테스트와 기존 테스트 회귀
|
||||
- 실제 `.harness/config.json`, preset 또는 solution에 따른 MSVC build/test
|
||||
- 새 빌드 경고와 범위 밖 변경
|
||||
- Harness 변경이면 관련 Python 테스트와 운영 문서의 필드·동작 일치
|
||||
|
||||
1. MSVC toolset와 C++ 표준이 AGENTS.md/ADR과 일치하는가?
|
||||
2. CMake 또는 MSBuild Debug/x64 빌드가 통과하는가?
|
||||
3. CTest 또는 `.harness/config.json`의 명시적 test command가 통과하는가?
|
||||
4. 새 C/C++ 소스와 헤더에 대응 테스트가 있는가?
|
||||
5. CRITICAL 아키텍처 규칙과 public header 경계를 지키는가?
|
||||
명시적으로 `validation: docs-only`인 문서 step은 실제 변경이 허용된 문서 경로에만
|
||||
있을 때 제품 build를 생략할 수 있다. 그 외에는 기존 전체 검증을 사용한다. 실행하지
|
||||
못한 검사는 성공으로 추정하지 않고 `미검증`과 이유를 기록한다.
|
||||
|
||||
## 출력 형식
|
||||
|
||||
실제 결함을 심각도순으로 먼저 제시한다. 각 finding에 파일과 줄 번호, 영향,
|
||||
재현 또는 근거, 구체적인 수정 방안을 포함한다.
|
||||
|
||||
그 뒤 다음 표를 제공한다.
|
||||
|
||||
| 항목 | 결과 | 비고 |
|
||||
|------|------|------|
|
||||
| 아키텍처 준수 | ✅/❌/미검증 | 상세 |
|
||||
| 기술 스택 준수 | ✅/❌/미검증 | 상세 |
|
||||
| 테스트 존재 | ✅/❌/미검증 | 상세 |
|
||||
| CRITICAL 규칙 | ✅/❌/미검증 | 상세 |
|
||||
| 빌드 가능 | ✅/❌/미검증 | 상세 |
|
||||
|
||||
실행할 수 없는 검사는 성공으로 추정하지 말고 `미검증`과 이유를 적는다. finding이
|
||||
없으면 발견된 문제가 없다고 명시하고 남아 있는 검증 공백을 설명한다.
|
||||
finding 뒤에는 아키텍처, 기술 스택, 테스트, 필수 규칙, build 가능 여부를 각각
|
||||
`통과`, `실패`, `미검증`으로 요약한다. finding이 없으면 발견된 문제가 없다고 쓰고
|
||||
남은 검증 공백을 밝힌다.
|
||||
|
||||
@@ -1,40 +1,43 @@
|
||||
# 프로젝트: {프로젝트명}
|
||||
# C++ 제품 개발 프로젝트
|
||||
|
||||
## 기술 스택
|
||||
이 저장소의 제품은 **MSVC 기반 C++ 제품**이다. Harness와 `scripts/`의 Python 코드는
|
||||
제품 개발을 지원하는 도구이며 제품의 목표나 기능이 아니다.
|
||||
|
||||
- MSVC toolset: {예: v143}
|
||||
- C++ 표준: {예: C++20}
|
||||
- 빌드 시스템: {CMake 또는 Visual Studio solution}
|
||||
- 테스트 프레임워크: {예: GoogleTest, Catch2, Microsoft C++ Unit Test}
|
||||
## 현재 확정 상태
|
||||
|
||||
## 아키텍처 규칙
|
||||
- 제품명·사용자·업무 기능: 미정
|
||||
- MSVC toolset·C++ 표준·테스트 프레임워크: 미정
|
||||
- 빌드 형식: CMake 또는 Visual Studio solution/project 중 실제 metadata와
|
||||
`.harness/config.json`으로 확인
|
||||
- 배치: 제품 구현은 `src/`, public header는 `include/`, 테스트는 `tests/`
|
||||
|
||||
- CRITICAL: {절대 지켜야 할 모듈 또는 계층 경계}
|
||||
- CRITICAL: public header와 implementation 의존성 방향을 역전하지 말 것
|
||||
- 제품 코드는 `src/`, public header는 `include/`, 테스트는 `tests/`에 둔다.
|
||||
미정 값을 예시로 채우거나 검증된 사실처럼 표현하지 않는다.
|
||||
|
||||
## 개발 프로세스
|
||||
## 필수 규칙
|
||||
|
||||
- CRITICAL: 새 기능과 버그 수정은 실패하는 테스트를 먼저 작성한 뒤 구현할 것.
|
||||
- CRITICAL: 빌드 경고를 새로 추가하지 말 것.
|
||||
- CRITICAL: public header가 implementation에 의존하도록 방향을 역전하지 않는다.
|
||||
- CRITICAL: 새 기능과 버그 수정은 실패하는 테스트를 먼저 확인한 뒤 구현한다.
|
||||
- CRITICAL: 새 빌드 경고를 추가하지 않는다.
|
||||
- 변경 범위와 관련된 문서·코드·테스트만 읽고 수정한다. step의 `context_files`가
|
||||
있으면 그 목록을 우선한다.
|
||||
- 실행 상태는 `phases/{phase}/index.json`을 기준으로 판단한다. 인계 문서는 상태를
|
||||
대신하지 않는다.
|
||||
- phase 작업 시 `docs/phase/{phase}/handoff.md`를 확인하고, 종료 전에 최신 인계와
|
||||
해당 `stepN.md` 기록을 갱신한다. 상세 양식은 `docs/phase/README.md`를 따른다.
|
||||
- 커밋 메시지는 conventional commits 형식을 따른다.
|
||||
|
||||
## 명령어
|
||||
## 검증
|
||||
|
||||
### CMake/CTest 프로젝트
|
||||
실제 명령은 `.harness/config.json`, CMake preset, solution 설정과 프로젝트 문서를
|
||||
우선한다. 설정이 확인되지 않았을 때 아래 명령을 성공한 검증으로 주장하지 않는다.
|
||||
|
||||
```powershell
|
||||
# 일반 CMake 예시
|
||||
cmake -S . -B .harness/build -A x64
|
||||
cmake --build .harness/build --config Debug
|
||||
ctest --test-dir .harness/build -C Debug --output-on-failure
|
||||
|
||||
# 직접 MSBuild 예시: 실제 solution과 test command로 교체
|
||||
MSBuild.exe <solution.sln> /m /p:Configuration=Debug /p:Platform=x64
|
||||
<test-command>
|
||||
```
|
||||
|
||||
### Visual Studio solution 프로젝트
|
||||
|
||||
```powershell
|
||||
MSBuild.exe MyProject.sln /m /p:Configuration=Debug /p:Platform=x64
|
||||
.\build\tests\Debug\MyProjectTests.exe
|
||||
```
|
||||
|
||||
실제 solution, preset, 테스트 명령은 `.harness/config.json`과 프로젝트 문서를
|
||||
우선한다.
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
# Architecture Decision Records
|
||||
|
||||
## 철학
|
||||
{프로젝트의 핵심 가치관 (예: MVP 속도 최우선. 외부 의존성 최소화. 작동하는 최소 구현을 선택.)}
|
||||
확정된 제품 기술 결정만 기록한다. 제품명, MSVC toolset, C++ 표준, 테스트 프레임워크,
|
||||
빌드 시스템과 도메인 설계는 현재 미정이다.
|
||||
|
||||
---
|
||||
## ADR-001: MSVC 기반 C++ 제품
|
||||
|
||||
### ADR-001: {결정 사항 (예: Next.js App Router 선택)}
|
||||
**결정**: {뭘 선택했는지}
|
||||
**이유**: {왜 선택했는지}
|
||||
**트레이드오프**: {뭘 포기했는지}
|
||||
- **상태:** 승인
|
||||
- **결정:** 대상 제품을 MSVC 기반 C++로 개발한다.
|
||||
- **이유:** 프로젝트의 확정된 제품 기술 방향이다.
|
||||
- **결과:** 실제 toolset과 C++ 표준은 제품 요구 및 build metadata가 정해질 때 별도로
|
||||
기록한다.
|
||||
|
||||
### ADR-002: {결정 사항}
|
||||
**결정**: {뭘 선택했는지}
|
||||
**이유**: {왜 선택했는지}
|
||||
**트레이드오프**: {뭘 포기했는지}
|
||||
## ADR-002: public header 경계
|
||||
|
||||
### ADR-003: {결정 사항}
|
||||
**결정**: {뭘 선택했는지}
|
||||
**이유**: {왜 선택했는지}
|
||||
**트레이드오프**: {뭘 포기했는지}
|
||||
- **상태:** 승인
|
||||
- **결정:** public header와 implementation의 의존성 방향을 유지하고 제품 구현,
|
||||
공개 header, 테스트를 각각 `src/`, `include/`, `tests/`에 둔다.
|
||||
- **이유:** 공개 계약과 구현 세부사항의 결합을 막기 위해서다.
|
||||
|
||||
## 새 결정 형식
|
||||
|
||||
새 ADR에는 상태, 결정, 이유, 대안 또는 trade-off, 영향을 받는 경로를 기록한다.
|
||||
Harness 사용법과 실행 상태는 제품 ADR 대신 Harness 문서와 phase index에 기록한다.
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
# 아키텍처
|
||||
# 제품 아키텍처
|
||||
|
||||
## 디렉토리 구조
|
||||
```
|
||||
src/
|
||||
├── app/ # 페이지 + API 라우트
|
||||
├── components/ # UI 컴포넌트
|
||||
├── types/ # TypeScript 타입 정의
|
||||
├── lib/ # 유틸리티 + 헬퍼
|
||||
└── services/ # 외부 API 래퍼
|
||||
## 확인된 구조
|
||||
|
||||
```text
|
||||
include/ public header
|
||||
src/ 제품 구현
|
||||
tests/ 제품 테스트
|
||||
```
|
||||
|
||||
## 패턴
|
||||
{사용하는 디자인 패턴 (예: Server Components 기본, 인터랙션이 필요한 곳만 Client Component)}
|
||||
- public header는 소비자가 implementation 세부사항에 의존하지 않도록 유지한다.
|
||||
- implementation에서 public 계약을 사용하며 의존성 방향을 역전하지 않는다.
|
||||
- 제품 모듈, entry point, 런타임 데이터 흐름과 배포 구조는 미정이다.
|
||||
|
||||
## 데이터 흐름
|
||||
```
|
||||
{데이터가 어떻게 흐르는지 (예:
|
||||
사용자 입력 → Client Component → API Route → 외부 API → 응답 → UI 업데이트
|
||||
)}
|
||||
```
|
||||
|
||||
## 상태 관리
|
||||
{상태 관리 방식 (예: 서버 상태는 Server Components, 클라이언트 상태는 useState/useReducer)}
|
||||
실제 디렉터리나 build metadata가 추가되면 확인된 구조만 이 문서에 기록한다.
|
||||
Harness의 Python 모듈 구조와 phase 운영 절차는 제품 아키텍처에 포함하지 않는다.
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
# MSVC C++ Harness 환경 구축 가이드
|
||||
|
||||
이 문서는 Windows에서 이 Harness framework를 설치하고, C++ 제품 저장소의
|
||||
MSVC 빌드·테스트 및 Codex step 실행을 준비하는 절차를 설명한다.
|
||||
명령은 별도 표시가 없으면 **대상 저장소 루트의 PowerShell**에서 실행한다.
|
||||
|
||||
현재 저장소에는 실행기, 훅, 설정 예제가 있으며 실제 C++ 제품 소스, CMake/Visual
|
||||
Studio 프로젝트, 실행할 `phases/`, Python 테스트 디렉터리는 없다. 따라서 도구
|
||||
설치 확인과 실제 제품 빌드 성공을 구분해야 한다. 아래 프로젝트명·preset 이름·경로는
|
||||
설정 형식을 보여 주는 예시이며, 제품의 MSVC toolset·C++ 표준·테스트 프레임워크는
|
||||
아직 확정되지 않았다.
|
||||
|
||||
## 1. 필요한 환경
|
||||
|
||||
| 구분 | 요구 사항 | 용도 및 확인 기준 |
|
||||
|---|---|---|
|
||||
| 운영체제 | 선택한 Visual Studio가 지원하는 Windows | 이 가이드는 Windows 네이티브 실행과 x64 빌드를 기준으로 한다. 현재 도구 탐색은 `vswhere.exe`, `MSBuild.exe`를 사용한다. |
|
||||
| 셸 | PowerShell | 설치 점검과 Windows 훅 명령 실행. 아래 UTF-8 파일 작성 예제는 PowerShell 7 기준이다. |
|
||||
| Git | Windows에서 실행 가능한 Git | 저장소 탐색, branch 전환, 자동 commit. `--push` 사용 시에만 원격 저장소 인증이 추가로 필요하다. |
|
||||
| Python | 3.10 이상, `python` 명령으로 실행 가능 | 실행기와 훅은 표준 라이브러리만 사용한다. 일반 실행에 `pip install`이나 가상환경은 필수가 아니다. |
|
||||
| Codex CLI | 아래 옵션·훅 호환성 점검을 통과하는 버전 | `codex exec`, JSON 출력, stdin 프롬프트와 프로젝트 훅을 지원해야 한다. 로그인과 서비스 네트워크 접근이 필요하다. |
|
||||
| C++ 빌드 도구 | Visual Studio 또는 Build Tools의 MSVC x64/x86 도구, MSBuild, Windows SDK | Visual Studio Installer에서 C++ 워크로드를 설치한다. VS Code만 설치해서는 충족되지 않는다. |
|
||||
| CMake 방식 | CMake와 CTest 3.20 이상, 제품이 요구하는 버전 이상 | `ctest --test-dir`와 build/test preset 기능에 필요한 하한이다. generator와 preset schema가 더 높은 버전을 요구할 수 있다. |
|
||||
| MSBuild 방식 | 실제 `.sln` 또는 `.vcxproj`, 테스트 실행 명령 | CMake/CTest는 이 방식의 Harness 검증에 필수가 아니다. |
|
||||
| 선택 도구 | Node.js/npm, Ninja, pytest/uv | Node.js/npm은 Codex의 npm 설치 방식을 선택할 때, Ninja는 해당 generator를 쓸 때만 필요하다. pytest/uv는 Harness 자체 테스트가 있는 checkout에서 사용한다. |
|
||||
|
||||
CMake 기능의 버전 근거는 [CMake 3.20 릴리스 노트](https://cmake.org/cmake/help/v3.20/release/3.20.html)와
|
||||
[CTest 명령 문서](https://cmake.org/cmake/help/latest/manual/ctest.1.html)를 참고한다.
|
||||
Windows·Visual Studio·Codex의 정확한 지원 버전 조합은 저장소에서 고정하지 않았다.
|
||||
팀에서 검증한 버전은 제품 도입 시 별도로 기록한다.
|
||||
|
||||
## 2. 기본 도구 설치
|
||||
|
||||
### Git과 Python
|
||||
|
||||
1. [Git for Windows 공식 설치 페이지](https://git-scm.com/install/windows)에서 설치하고
|
||||
PowerShell과 외부 프로그램에서 `git`을 실행할 수 있도록 PATH를 설정한다.
|
||||
2. [Python Windows 공식 배포 페이지](https://www.python.org/downloads/windows/)에서
|
||||
3.10 이상의 Python을 설치한다. 실행기뿐 아니라 자식 훅에서도 같은 Python을 찾도록
|
||||
실제 설치 디렉터리를 사용자 PATH에 등록한다.
|
||||
3. 새 PowerShell 창을 열고 확인한다.
|
||||
|
||||
```powershell
|
||||
git --version
|
||||
python --version
|
||||
python -c "import sys; print(sys.executable); assert sys.version_info >= (3, 10)"
|
||||
Get-Command git, python | Select-Object Name, Source
|
||||
```
|
||||
|
||||
`python`이 Microsoft Store를 열거나 실행되지 않으면 Windows의 앱 실행 별칭과
|
||||
PATH 순서를 확인한다. `py`만 동작하는 상태는 충분하지 않다. 현재 Windows 훅이
|
||||
`python` 명령을 사용하기 때문이다. 가상환경을 쓰면 활성화한 동일 터미널에서
|
||||
Harness를 실행한다.
|
||||
|
||||
### Visual Studio와 C++ 도구
|
||||
|
||||
1. [Microsoft의 MSVC 설치 안내](https://learn.microsoft.com/en-us/cpp/overview/acquire-msvc)를
|
||||
따라 Visual Studio Installer를 실행한다. IDE가 필요하면 Visual Studio,
|
||||
명령줄 빌드만 필요하면 Build Tools 설치를 선택한다.
|
||||
2. **C++를 사용한 데스크톱 개발(Desktop development with C++)** 워크로드를 선택한다.
|
||||
3. 설치 세부 정보에서 MSVC x64/x86 빌드 도구, MSBuild, 제품이 요구하는 Windows SDK가
|
||||
포함되는지 확인한다. 기존 제품이면 해당 `PlatformToolset`과 SDK 버전을 설치한다.
|
||||
4. CMake 방식이면 **Windows용 C++ CMake 도구**도 선택하거나 CMake를 별도 설치한다.
|
||||
|
||||
설치 후 아래 조회에서 실제 설치 경로와 `MSBuild.exe` 경로가 나와야 한다.
|
||||
|
||||
```powershell
|
||||
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio/Installer/vswhere.exe'
|
||||
Test-Path -LiteralPath $vswhere
|
||||
& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
|
||||
& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -find 'MSBuild\**\Bin\MSBuild.exe'
|
||||
```
|
||||
|
||||
Harness도 이 C++ component ID로 설치를 찾는다. 여러 Visual Studio 설치가 있으면
|
||||
조건에 맞는 최신 설치를 선택하며, `.harness/config.json`에는 Visual Studio 설치 경로를
|
||||
고정하는 필드가 없다. 특정 toolset 선택은 제품의 preset 또는 프로젝트 metadata에서 한다.
|
||||
|
||||
### CMake 사용 시 추가 점검
|
||||
|
||||
Harness는 PATH의 `cmake.exe`와 `ctest.exe`를 우선하고, 없으면 탐색한 Visual Studio의
|
||||
번들 디렉터리를 사용한다. 아래는 번들 경로를 현재 터미널 PATH에 추가하는 예시다.
|
||||
|
||||
```powershell
|
||||
$vsInstall = & $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
|
||||
$cmakeBin = Join-Path $vsInstall 'Common7/IDE/CommonExtensions/Microsoft/CMake/CMake/bin'
|
||||
if (Test-Path -LiteralPath (Join-Path $cmakeBin 'cmake.exe')) {
|
||||
$env:PATH = "$cmakeBin;$env:PATH"
|
||||
}
|
||||
cmake --version
|
||||
ctest --version
|
||||
Get-Command cmake, ctest | Select-Object Name, Source
|
||||
```
|
||||
|
||||
두 도구가 같은 CMake 배포본을 가리키도록 맞춘다. 새 터미널에서도 수동 명령을
|
||||
사용하려면 해당 디렉터리를 사용자 PATH에 등록한다.
|
||||
|
||||
Visual Studio generator와 직접 MSBuild는 설치된 도구를 활용한다. Ninja preset이나
|
||||
`cl.exe` 직접 실행을 쓰는 경우에는 **Developer PowerShell for VS** 또는
|
||||
**x64 Native Tools Command Prompt for VS**로 개발 환경을 먼저 연다. 명령 프롬프트에서
|
||||
이 문서의 예제를 실행하려면 그 창에서 PowerShell을 시작한다. Harness는
|
||||
`VsDevCmd.bat`를 자동 실행하지 않으므로 compiler/include/library 환경은 호출자에게서
|
||||
상속된다. Ninja preset이면 같은 터미널에서 `ninja --version`도 확인한다.
|
||||
|
||||
## 3. Codex CLI 설치와 호환성 확인
|
||||
|
||||
[공식 Codex CLI 설치 안내](https://developers.openai.com/codex/cli)의 Windows 설치
|
||||
방법으로 CLI를 설치한 뒤 새 터미널을 연다. npm 방식을 선택한 경우에는 먼저
|
||||
[Node.js 공식 배포 페이지](https://nodejs.org/en/download)에서 Node.js/npm을 설치한다.
|
||||
Harness는 Codex 앱 설치 여부와 별개로 `codex` 명령을 직접 실행한다.
|
||||
|
||||
```powershell
|
||||
codex --version
|
||||
codex login
|
||||
codex login status
|
||||
codex exec --help
|
||||
```
|
||||
|
||||
`codex login`의 안내에 따라 인증한다. Harness 설정에는 계정이나 API 키를 넣지 않는다.
|
||||
현재 실행기가 사용하는 옵션은 다음과 같다.
|
||||
|
||||
```text
|
||||
codex exec --json --sandbox workspace-write --dangerously-bypass-hook-trust --cd <repository-root> -
|
||||
```
|
||||
|
||||
`exec --help`에서 이 옵션들이 지원되는지 확인한다. 저장소는 최소 Codex 버전을
|
||||
선언하지 않으므로 버전 숫자만으로 호환성을 판단하지 않는다. 문서 작성 시 로컬 CLI
|
||||
`0.153.4`에서 옵션 목록은 확인했지만, 이것이 전체 실행 검증을 의미하지는 않는다.
|
||||
|
||||
실행기는 셸 alias나 PowerShell 함수 대신 Python 자식 프로세스로 Codex를 호출한다.
|
||||
동일한 경로로 실행 가능한지 다음 명령으로 확인한다. 모델 호출은 발생하지 않는다.
|
||||
|
||||
```powershell
|
||||
python -X utf8 -c "import subprocess; subprocess.run(['codex', '--version'], check=True)"
|
||||
python -X utf8 -c "import subprocess; subprocess.run(['codex', 'exec', '--help'], check=True)"
|
||||
```
|
||||
|
||||
터미널의 `codex`는 되는데 위 명령이 실패하면 `Get-Command codex -All`과
|
||||
`where.exe codex`로 PATH를 확인한다. `.ps1` alias만 보이거나 npm shim이 자식
|
||||
프로세스에서 실행되지 않으면 공식 Windows 설치 방식으로 CLI를 설치하고 실제
|
||||
실행 파일 디렉터리를 PATH에 등록한 뒤 다시 점검한다.
|
||||
|
||||
## 4. 저장소 준비와 훅 확인
|
||||
|
||||
Git으로 저장소를 clone하거나, 기존 제품 저장소에 다음 Harness 구성요소를 가져온다.
|
||||
기존 `AGENTS.md`와 훅 설정이 있으면 제품 규칙을 유지하면서 병합한다.
|
||||
|
||||
```text
|
||||
AGENTS.md
|
||||
.agents/skills/harness/SKILL.md
|
||||
.agents/skills/review/SKILL.md
|
||||
.codex/hooks.json
|
||||
.harness/config.example.json
|
||||
scripts/execute.py
|
||||
scripts/hooks/
|
||||
scripts/msvc_harness/
|
||||
docs/HARNESS.md
|
||||
docs/HARNESS_WORKFLOW.md
|
||||
docs/ENVIRONMENT_SETUP.md
|
||||
```
|
||||
|
||||
실행기와 훅이 경로를 기준으로 저장소를 찾으므로 위 배치를 유지한다. 제품 구현은
|
||||
`src/`, 공개 header는 `include/`, 테스트는 `tests/`에 배치한다.
|
||||
|
||||
```powershell
|
||||
git rev-parse --show-toplevel
|
||||
git status --short
|
||||
git config user.name
|
||||
git config user.email
|
||||
python -X utf8 scripts/execute.py --help
|
||||
```
|
||||
|
||||
Git 작성자 정보가 없으면 대상 저장소에서 아래 예시를 자신의 정보로 바꾸어 설정한다.
|
||||
실제 실행 전에는 준비한 설정·phase 파일을 검토하여 commit하고, 다른 작업의 변경이
|
||||
없는 전용 checkout/worktree를 사용한다. 실행기는 `git add -A`와 자동 commit을 수행한다.
|
||||
|
||||
```powershell
|
||||
git config --local user.name 'YOUR_NAME'
|
||||
git config --local user.email 'YOUR_EMAIL'
|
||||
```
|
||||
|
||||
저장소 루트에서 `codex`를 실행하고 `/hooks`에서 `.codex/hooks.json`의 `PreToolUse`와
|
||||
`Stop`이 로드되는지 확인한다. 프로젝트 신뢰 설정과 훅의 실행 상태도 확인한다.
|
||||
공식 훅 문서는 프로젝트 훅의 신뢰 요구사항과 Windows용 `commandWindows` 필드를
|
||||
설명한다. [Codex 훅 문서](https://developers.openai.com/codex/hooks)
|
||||
|
||||
현재 실행기는 검토된 자동화용 `--dangerously-bypass-hook-trust`를 사용한다. 실행 전
|
||||
저장소 훅 내용을 확인해야 하며, 이 옵션은 비활성 훅을 활성화하거나 빌드 설정을
|
||||
대신하지 않는다. 사용자·관리자 설정에서 훅이 꺼져 있거나 프로젝트 훅이 제한되면
|
||||
이를 먼저 해결한다.
|
||||
|
||||
Windows 훅은 `python -X utf8`과 Git 루트 탐색을 사용한다. 따라서 Codex가 실행하는
|
||||
훅 환경에서도 Python과 Git이 보여야 한다. 아래 직접 호출은 훅 스크립트의 기본 실행만
|
||||
확인하며, Codex가 훅을 자동 호출하는지까지 검증하지는 않는다.
|
||||
|
||||
```powershell
|
||||
'{"tool_name":"Edit","tool_input":{"file_path":"docs/ENVIRONMENT_SETUP.md"}}' | python -X utf8 scripts/hooks/pre_tool_use.py
|
||||
$LASTEXITCODE
|
||||
```
|
||||
|
||||
통과 기준은 종료 코드 `0`이고 입력 해석 실패 메시지가 없는 것이다. C++ 편집에는
|
||||
대응 테스트 파일이 먼저 필요하다. 기본 이름은 `{stem}_test.cpp`, `{stem}_tests.cpp`,
|
||||
`test_{stem}.cpp`, `{stem}.test.cpp`이며 실제 실패 테스트 확인은 별도로 수행해야 한다.
|
||||
|
||||
## 5. 제품 빌드 설정
|
||||
|
||||
`.harness/config.example.json`은 모든 필드를 보여 주는 참고 자료다. `MyProject.sln`,
|
||||
`windows-debug`, 테스트 실행 파일은 이 저장소에 존재하지 않으므로 그대로 복사해
|
||||
완성된 설정으로 사용하지 않는다. 아래 방식 중 실제 프로젝트에 맞는 하나로
|
||||
`.harness/config.json`을 작성한다. JSON은 **UTF-8 BOM 없이**, 주석 없이 저장한다.
|
||||
PowerShell 7에서 텍스트를 저장할 때는 `Set-Content -Encoding utf8NoBOM`을 사용할 수 있다.
|
||||
|
||||
### A. CMake, preset 없이 사용
|
||||
|
||||
실제 루트 `CMakeLists.txt`가 있는 프로젝트의 최소 설정이다.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"projectType": "cmake",
|
||||
"cmake": { "sourceDir": "." }
|
||||
}
|
||||
```
|
||||
|
||||
이 방식은 Harness가 `.harness/build`, `Debug`, `x64`를 사용한다. 프로젝트에 맞는
|
||||
Visual Studio generator가 선택되는 환경에서 다음 순서로 확인한다. generator·구성·
|
||||
toolset을 명확히 고정해야 하는 제품은 B 방식의 preset을 사용한다.
|
||||
|
||||
```powershell
|
||||
cmake -S . -B .harness/build -A x64
|
||||
cmake --build .harness/build --config Debug
|
||||
ctest --test-dir .harness/build -C Debug --show-only=json-v1
|
||||
ctest --test-dir .harness/build -C Debug --output-on-failure
|
||||
```
|
||||
|
||||
각 명령이 성공해야 하고, 테스트 발견 JSON의 `tests` 배열에는 적어도 한 개의
|
||||
테스트가 있어야 한다. 제품 CMake에서 C++ 언어와 테스트를 활성화하고 실제 테스트를
|
||||
등록한다. Harness는 생성된 `CMakeCXXCompiler.cmake`의 compiler ID가 `MSVC`인지
|
||||
확인하므로 MinGW/GCC 또는 Clang으로 설정된 build directory는 통과하지 않는다.
|
||||
|
||||
### B. CMake preset 사용
|
||||
|
||||
다음은 **이미 `windows-debug` configure/build/test preset이 정의된 제품**의 예시다.
|
||||
실제 `CMakePresets.json` 또는 `CMakeUserPresets.json`의 이름과 binary directory로 바꾼다.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"projectType": "cmake",
|
||||
"cmake": {
|
||||
"sourceDir": ".",
|
||||
"binaryDir": "out/build/windows-debug",
|
||||
"configurePreset": "windows-debug",
|
||||
"buildPreset": "windows-debug",
|
||||
"testPreset": "windows-debug"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`binaryDir`, `configurePreset`, `buildPreset`, `testPreset`은 네 개를 함께 지정해야 한다.
|
||||
`binaryDir`만 바꾸는 설정은 지원하지 않는다. 세 preset은 동일한 빌드 트리를 사용해야
|
||||
하며 MSVC generator/toolset·아키텍처·빌드 구성을 제품 요구에 맞게 정의한다.
|
||||
[CMake preset 공식 문서](https://cmake.org/cmake/help/latest/manual/cmake-presets.7.html)
|
||||
|
||||
아래 명령은 설정의 `sourceDir`에서 실행한다. 이름은 실제 preset으로 바꾼다.
|
||||
|
||||
```powershell
|
||||
cmake --list-presets
|
||||
cmake --build --list-presets
|
||||
ctest --list-presets
|
||||
cmake --preset windows-debug
|
||||
cmake --build --preset windows-debug
|
||||
ctest --preset windows-debug --show-only=json-v1
|
||||
ctest --preset windows-debug --output-on-failure
|
||||
```
|
||||
|
||||
### C. Visual Studio solution/project와 MSBuild 사용
|
||||
|
||||
아래 파일명·출력 경로를 실제 `.sln` 또는 `.vcxproj`와 테스트 실행 파일로 바꾼다.
|
||||
테스트 프레임워크는 지정하지 않으며, 실패할 때 nonzero로 종료하는 테스트 명령이 필요하다.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"projectType": "msbuild",
|
||||
"msbuild": {
|
||||
"solution": "MyProject.sln",
|
||||
"configuration": "Debug",
|
||||
"platform": "x64",
|
||||
"testCommand": ["build/tests/Debug/MyProjectTests.exe"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`testCommand`는 실행 파일과 인자를 분리한 문자열 배열이다. 명령 전체를 한 문자열로
|
||||
넣거나 `&&`, 파이프, 셸 변수 확장을 기대하지 않는다. 작업 디렉터리는 저장소 루트다.
|
||||
실행 파일에 경로를 쓰면 저장소 내부여야 하며, 파일명만 쓰면 PATH에서 찾는다.
|
||||
|
||||
```powershell
|
||||
$msbuildExe = & $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -find 'MSBuild\**\Bin\MSBuild.exe' | Select-Object -First 1
|
||||
& $msbuildExe MyProject.sln /m /nologo /p:Configuration=Debug /p:Platform=x64
|
||||
& ./build/tests/Debug/MyProjectTests.exe
|
||||
```
|
||||
|
||||
빌드와 테스트 각각의 종료 코드가 `0`이어야 한다. 직접 MSBuild 방식에서 CTest 등록은
|
||||
필수가 아니지만 `msbuild.testCommand`는 필수다. 현재 탐색기는 `.slnx`를 지원하지 않는다.
|
||||
|
||||
### 공통 설정 점검
|
||||
|
||||
`sourceDir`, `binaryDir`, `solution`, `tdd.testRoots`는 저장소 상대 경로로 지정하며
|
||||
저장소 밖을 가리킬 수 없다. 알 수 없는 설정 필드는 오류다. `projectType: "auto"`는
|
||||
루트 CMake metadata → 단일 루트 `.sln` → 단일 루트 `.vcxproj` 순서로 찾는다.
|
||||
하위 디렉터리 제품이나 빌드 형식이 여러 개인 저장소는 `projectType`과 경로를 명시한다.
|
||||
|
||||
구문과 설정 형식을 빌드 없이 확인한다.
|
||||
|
||||
```powershell
|
||||
python -X utf8 -m json.tool .harness/config.json
|
||||
python -X utf8 -c "import sys; from pathlib import Path; sys.path.insert(0, 'scripts'); from msvc_harness.config import load_config; print(load_config(Path.cwd()))"
|
||||
```
|
||||
|
||||
현재 `.gitignore`는 `.harness/build/`를 제외하지만 `.harness/config.json`,
|
||||
`out/build/`, `.venv/`는 제외하지 않는다. 제품 저장소에서 공유할 설정과 로컬 파일·
|
||||
빌드 산출물을 정하고 자동 staging 전에 필요한 ignore 규칙을 적용한다.
|
||||
|
||||
## 6. 구축 완료 확인과 최초 실행
|
||||
|
||||
제품 metadata와 설정을 준비한 뒤 Stop 훅 검증을 수동 실행한다. 실제 빌드·테스트와
|
||||
빌드 산출물 작성이 발생한다.
|
||||
|
||||
```powershell
|
||||
python -X utf8 scripts/hooks/stop_validation.py
|
||||
```
|
||||
|
||||
이 스크립트는 **실패해도 프로세스 종료 코드가 0일 수 있다**. 표준 출력에
|
||||
`"continue": false`와 `stopReason`이 나오면 검증 실패다. C++ 파일도 metadata도 없는
|
||||
초기 저장소는 빌드를 수행하지 않고 조용히 종료하므로 MSVC 검증 성공으로 기록하지
|
||||
않는다. `CODEX_STOP_VALIDATION_ACTIVE=1`이 설정된 재진입 환경이나 docs-only 실행
|
||||
문맥에서도 검증이 생략될 수 있으므로 수동 점검은 일반 터미널에서 수행한다.
|
||||
|
||||
제품 검증 후 [실행 흐름](HARNESS_WORKFLOW.md)과
|
||||
[Harness 스킬의 phase 형식](../.agents/skills/harness/SKILL.md)에 맞춰 실제 작업의
|
||||
`phases/index.json`, `phases/<phase>/index.json`, `stepN.md`를 준비한다.
|
||||
`context_files`에는 존재하는 파일만 넣는다. 현재 checkout에는 기존 문서가 참조하는
|
||||
`docs/phase/README.md`도 없으므로 기록은 실행기가 명시한 다음 내용을 기준으로 준비한다.
|
||||
|
||||
- `docs/phase/<phase>/handoff.md`: 현재 상황, 결정과 이유, 미해결 문제, 검증 결과,
|
||||
다음 시작점과 상세 기록 링크를 약 60줄로 유지한다.
|
||||
- `docs/phase/<phase>/stepN.md`: 변경 파일, 결정, 검증, 실패한 접근과 이유를 기록한다.
|
||||
- 실행 상태의 기준은 `phases/<phase>/index.json`이며 인계 기록은 상태를 대신하지 않는다.
|
||||
|
||||
`<phase>`를 실제 디렉터리 이름으로 바꾸어 실행한다.
|
||||
|
||||
```powershell
|
||||
python -X utf8 scripts/execute.py <phase> --dry-run
|
||||
python -X utf8 scripts/execute.py <phase>
|
||||
```
|
||||
|
||||
`--dry-run`은 첫 pending step의 문맥 경로와 프롬프트 크기만 확인하며 Codex, Git 변경,
|
||||
인증, 빌드·테스트는 실행하지 않는다. 실제 실행은 branch 전환, 파일 수정과 자동
|
||||
commit을 수행한다. 원격 전송이 필요할 때만 `--push`를 사용한다.
|
||||
|
||||
구축 완료 기준은 다음과 같다.
|
||||
|
||||
- Python에서 `codex --version`과 `exec --help`를 직접 호출할 수 있고 인증이 되어 있다.
|
||||
- Codex에서 프로젝트 훅이 로드되고 실제 도구 호출·종료 시 동작한다.
|
||||
- 실제 제품의 MSVC build와 테스트가 성공하고 Stop 훅에서 차단 응답이 없다.
|
||||
- 실제 phase의 dry-run에서 필요한 문맥을 읽고 첫 step 실행 결과와 index 상태가 일치한다.
|
||||
|
||||
Harness 자체를 수정하는 checkout에 Python 테스트가 있다면 별도로 아래를 실행한다.
|
||||
현재 checkout에는 `tests/`가 없어 pytest 통과를 구축 완료 근거로 사용할 수 없다.
|
||||
|
||||
```powershell
|
||||
python -m pip install pytest
|
||||
python -m pytest -v -rs
|
||||
python -m compileall -q scripts
|
||||
```
|
||||
|
||||
## 7. 자주 발생하는 문제
|
||||
|
||||
| 증상 | 확인 및 조치 |
|
||||
|---|---|
|
||||
| `python` 또는 `codex`를 찾지 못함 | 새 터미널에서 PATH와 앱 실행 별칭을 확인한다. Python 자식 프로세스 점검까지 통과해야 한다. |
|
||||
| `unexpected argument` / `unrecognized option` | `codex exec --help`로 실행기의 옵션 지원 여부를 확인하고 호환 CLI를 설치한다. |
|
||||
| `not logged in` / 인증 실패 | Harness를 실행하는 동일 Windows 계정과 Codex 환경에서 다시 로그인한다. |
|
||||
| 훅이 보이지 않거나 실행되지 않음 | `/hooks`, 프로젝트 신뢰, 사용자·관리자 훅 설정, `commandWindows` 지원과 자식 환경의 PATH를 확인한다. |
|
||||
| `vswhere.exe` 또는 MSBuild를 찾지 못함 | Visual Studio Installer에서 C++ x64/x86 도구와 MSBuild를 설치·복구한다. 조회 결과가 빈 문자열인지도 확인한다. |
|
||||
| CMake가 `-A x64`를 거부함 | 기본 generator가 Ninja 등인지 확인한다. Visual Studio generator 환경을 사용하거나 MSVC 개발 환경과 preset을 구성한다. |
|
||||
| MSVC compiler metadata 오류 | 잘못된 compiler로 configure했는지, preset의 실제 binary directory와 `binaryDir`가 같은지 확인한다. |
|
||||
| `CTest discovered no tests` | 제품 CMake의 테스트 활성화·등록, test preset과 구성 필터를 확인한다. 실행 파일 존재만으로는 충분하지 않다. |
|
||||
| 여러 solution / build metadata 없음 | `projectType`과 실제 프로젝트 경로를 명시한다. C++ 파일만 복사한 상태로는 build를 검증할 수 없다. |
|
||||
| `TDD GUARD` 차단 | 대응 테스트를 먼저 추가하고 red 단계 확인 후 제품 파일을 수정한다. |
|
||||
| `dubious ownership` | 가능하면 현재 계정 소유의 checkout을 사용한다. 신뢰하는 공유 경로라면 해당 경로 하나만 Git `safe.directory`에 등록하고 자식 계정에서도 적용되는지 확인한다. |
|
||||
| phase 또는 context 파일을 찾지 못함 | 현재 저장소에는 기본 phase가 없다. 실제 phase/index/step과 참조 파일을 먼저 작성한다. |
|
||||
| 빌드·Codex 시간 초과 | Stop 검증 전체와 Codex 호출 각각의 제한은 1,800초다. 로그의 실패 단계와 도구 경로를 확인한다. |
|
||||
|
||||
운영 중 재시도, `error`/`blocked` 복구와 상태 기록은
|
||||
[Harness 운영 가이드](HARNESS.md)와 [실행 흐름](HARNESS_WORKFLOW.md)을 참고한다.
|
||||
@@ -1,120 +1,118 @@
|
||||
# Harness 운영 가이드
|
||||
|
||||
요구사항 접수부터 Step 실행, TDD 정책, MSVC 빌드·테스트, 재시도와 커밋까지의
|
||||
전체 흐름은 [Harness Framework 동작 과정](HARNESS_WORKFLOW.md)을 참고한다.
|
||||
Harness는 MSVC C++ 제품 개발의 step 실행, 정책 검사와 build/test를 지원한다. 제품의
|
||||
목표와 기능은 `docs/PRD.md`에 별도로 둔다. 상세 실행 흐름은
|
||||
[HARNESS_WORKFLOW.md](HARNESS_WORKFLOW.md)를 참고한다.
|
||||
|
||||
## Requirements
|
||||
## 요구 환경과 설정
|
||||
|
||||
Windows, Python 3.10 이상, Codex CLI가 필요하다. CMake 프로젝트에는 Visual Studio의
|
||||
Desktop development with C++ 워크로드와 MSBuild, CMake/CTest를 설치한다.
|
||||
처음 사용하는 환경의 도구 설치, PATH·인증·훅 점검과 빌드 방식별 설정은
|
||||
[환경 구축 가이드](ENVIRONMENT_SETUP.md)를 따른다.
|
||||
|
||||
## 프로젝트 자동 감지
|
||||
|
||||
프로젝트 형식은 다음 순서로 결정한다: `.harness/config.json`의 명시적 type, 루트의
|
||||
CMake metadata, 하나의 `.sln`, 하나의 `.vcxproj` 순서다. C/C++가 아닌 저장소는
|
||||
건너뛰며, C/C++ 파일은 있지만 CMake/solution metadata가 없는 orphan-C++ 저장소는
|
||||
오류로 처리한다.
|
||||
|
||||
설정을 시작하려면 다음을 실행한다.
|
||||
- Windows, Python 3.10 이상, Codex CLI
|
||||
- Visual Studio Desktop development with C++ workload와 MSBuild
|
||||
- CMake 프로젝트이면 CMake와 CTest
|
||||
|
||||
```powershell
|
||||
Copy-Item .harness/config.example.json .harness/config.json
|
||||
```
|
||||
|
||||
실제 preset, solution, configuration, platform과 test command를
|
||||
`.harness/config.json`에 기록한다. 제품의 MSVC toolset, C++ 표준과 테스트 프레임워크는
|
||||
현재 미정이며 예시값으로 확정하지 않는다.
|
||||
|
||||
## Phase 실행
|
||||
|
||||
```powershell
|
||||
python scripts/execute.py <phase-name> --dry-run
|
||||
python scripts/execute.py <phase-name>
|
||||
python scripts/execute.py <phase-name> --push
|
||||
```
|
||||
|
||||
## Harness Python 검증
|
||||
먼저 `--dry-run`으로 첫 pending step이 읽을 파일과 프롬프트 문자 수를 확인한다. 이
|
||||
모드는 Codex 호출, branch 전환, timestamp, 파일 쓰기, commit과 push를 하지 않는다.
|
||||
`--push`와 함께 사용할 수 없다.
|
||||
|
||||
이 저장소의 테스트와 최종 acceptance 검증은 pytest를 시스템 Python에 설치하지 않고
|
||||
다음 명령으로 실행한다.
|
||||
## 선택 문맥과 호환성
|
||||
|
||||
```powershell
|
||||
uv run --with pytest python -m pytest -v -rs
|
||||
```
|
||||
|
||||
## CMake preset 설정
|
||||
|
||||
`projectType`을 `cmake`로 지정하거나 자동 감지를 사용한다. `cmake.sourceDir`,
|
||||
`binaryDir`, `configurePreset`, `buildPreset`, `testPreset`은 preset을 사용할 때 함께
|
||||
지정해야 한다. 빌드 산출물은 저장소의 `.harness/build/`처럼 격리된 경로에 둔다.
|
||||
step index의 `context_files`에 필요한 기존 문서를 저장소 상대 경로로 적는다.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"projectType": "cmake",
|
||||
"cmake": {
|
||||
"sourceDir": ".",
|
||||
"binaryDir": "out/build/windows-debug",
|
||||
"configurePreset": "windows-debug",
|
||||
"buildPreset": "windows-debug",
|
||||
"testPreset": "windows-debug"
|
||||
}
|
||||
"step": 1,
|
||||
"name": "implement-parser",
|
||||
"status": "pending",
|
||||
"context_files": ["docs/ARCHITECTURE.md", "docs/parser-format.md"]
|
||||
}
|
||||
```
|
||||
|
||||
```powershell
|
||||
cmake --preset windows-debug
|
||||
cmake --build --preset windows-debug
|
||||
ctest --preset windows-debug --output-on-failure
|
||||
필드를 생략하거나 빈 배열로 두면 `AGENTS.md`, 자동 인계와 step 이외의 문서는
|
||||
추가되지 않는다. 이전 Harness가 모든 `docs/*.md`를 넣던 동작에 의존하는 기존 phase는
|
||||
필요한 문서를 `context_files`에 명시해야 한다. glob, URL, 절대 경로와 저장소 밖 경로는
|
||||
지원하지 않는다.
|
||||
|
||||
현재 phase의 `docs/phase/{phase}/handoff.md`가 있으면 매 호출 다시 읽는다. 없을 때만
|
||||
현재 step보다 앞선 완료 step 중 가장 최근 하나의 summary를 최대 1,000자로 전달한다.
|
||||
상세 기록 형식은 [phase README](phase/README.md)를 따른다.
|
||||
|
||||
## 검증 모드
|
||||
|
||||
```json
|
||||
{"step": 2, "name": "update-docs", "status": "pending", "validation": "docs-only"}
|
||||
```
|
||||
|
||||
Preset을 쓰지 않는 경우에는 같은 격리된 build directory를 명시한다.
|
||||
`validation` 생략 또는 `full`은 기존 전체 제품 검증을 수행한다. `docs-only`도 실제 Git
|
||||
변경이 루트 `AGENTS.md`, `docs/**/*.md`, 현재 phase의 `index.json`과
|
||||
`stepN-output.json`에만 있고 실제 문서 변경이 있을 때만 제품 검증을 생략한다. Git
|
||||
조회 실패, 빈 변경, bookkeeping만 있는 변경, 다른 코드·설정·빌드 파일이 섞인 변경은
|
||||
보수적으로 전체 검증으로 돌아간다. symlink나 junction으로 해석되는 변경 경로도
|
||||
문서 전용으로 인정하지 않는다. 제품 build가 Markdown을 입력으로 사용하면 `full`을
|
||||
선택한다.
|
||||
|
||||
## MSVC 검증
|
||||
|
||||
자동 감지는 명시 `projectType`, CMake metadata, 단일 `.sln`, 단일 `.vcxproj` 순서다.
|
||||
C++ 파일은 있지만 build metadata가 없으면 오류다. 여러 solution/project가 있으면
|
||||
설정으로 하나를 지정한다.
|
||||
|
||||
CMake preset을 쓸 때는 source, binary, configure/build/test preset을 함께 지정한다.
|
||||
일반 CMake 검증의 형태는 다음과 같다.
|
||||
|
||||
```powershell
|
||||
cmake -S . -B .harness/build -A x64
|
||||
cmake --build .harness/build --config Debug
|
||||
ctest --test-dir .harness/build -C Debug --show-only=json-v1
|
||||
ctest --test-dir .harness/build -C Debug --output-on-failure
|
||||
```
|
||||
|
||||
## 직접 MSBuild 설정
|
||||
|
||||
`projectType`을 `msbuild`로 설정하면 `msbuild.solution`, `configuration`, `platform`을
|
||||
지정한다. 직접 MSBuild 프로젝트에서는 `msbuild.testCommand`가 필수이며, 테스트 실행
|
||||
파일과 인수를 JSON 배열로 적는다.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"projectType": "msbuild",
|
||||
"msbuild": {
|
||||
"solution": "MyProject.sln",
|
||||
"configuration": "Debug",
|
||||
"platform": "x64",
|
||||
"testCommand": ["build/tests/Debug/MyProjectTests.exe"]
|
||||
}
|
||||
}
|
||||
```
|
||||
Harness는 compiler metadata가 MSVC인지와 CTest가 한 개 이상의 테스트를 찾는지도
|
||||
확인한다. 직접 MSBuild는 solution, configuration, platform과 `msbuild.testCommand`가
|
||||
필요하다.
|
||||
|
||||
```powershell
|
||||
MSBuild.exe MyProject.sln /m /p:Configuration=Debug /p:Platform=x64
|
||||
.\build\tests\Debug\MyProjectTests.exe
|
||||
MSBuild.exe <solution.sln> /m /nologo /p:Configuration=Debug /p:Platform=x64
|
||||
<configured-test-command>
|
||||
```
|
||||
|
||||
## TDD 확장
|
||||
## TDD와 복구
|
||||
|
||||
`tdd.testRoots`와 `tdd.testPatterns`로 테스트 위치와 이름을 확장한다. 패턴마다
|
||||
`{stem}`이 필요하다. `main`, 테스트, 외부 의존성, 생성 파일, build directory 같은
|
||||
기본 제외 항목은 Harness가 관리하며, `tdd.exclude`의 사용자 제외 항목은 이를
|
||||
대체하지 않고 추가한다.
|
||||
C/C++ 제품 파일을 편집하려면 설정된 test root나 인접 `tests/`, `test/`에 대응 테스트
|
||||
파일이 먼저 존재해야 한다. 테스트 자체, `main.cpp`, build·외부 의존성·generated
|
||||
경로와 `tdd.exclude`는 기존 정책대로 처리한다. 이 검사는 테스트의 존재를 확인하며
|
||||
테스트 품질이나 실제 red-green 실행을 대신하지 않는다.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"tdd": {
|
||||
"testRoots": ["tests", "integration-tests"],
|
||||
"testPatterns": ["{stem}_test.cpp", "test_{stem}.cpp"],
|
||||
"exclude": ["legacy/generated/**"]
|
||||
}
|
||||
}
|
||||
Executor는 Codex를 최초 호출 포함 최대 3회 호출한다. 재시도 입력은 marker를 포함해
|
||||
4,000자로 제한하며 앞 1,500자와 끝부분 최대 2,500자를 보존한다. 원본
|
||||
`stepN-output.json`은 온전히
|
||||
저장되지만 다음 시도에서 덮어쓴다. exit code 0과 `completed` 상태가 모두 있어야
|
||||
성공한다.
|
||||
|
||||
`error` 또는 `blocked`를 재개할 때는 원인을 해결하고 step을 `pending`으로 바꾼 뒤
|
||||
오류 이유와 해당 timestamp를 제거한다. 인증이나 CLI 환경 오류는 즉시 중단한다.
|
||||
|
||||
Harness 자체 Python 검증:
|
||||
|
||||
```powershell
|
||||
uv run --with pytest python -m pytest -v -rs
|
||||
uv run --with pytest python -m compileall -q scripts tests
|
||||
```
|
||||
|
||||
## 실패 복구
|
||||
|
||||
- Visual Studio C++ workload가 없으면 Installer에서 Desktop development with C++를 설치한 뒤 다시 실행한다.
|
||||
- solution 또는 project가 여러 개라서 모호하면 `projectType`과 `msbuild.solution`을 명시한다.
|
||||
- MSVC가 아닌 컴파일러가 감지되면 MSVC Developer Command Prompt에서 실행하거나 toolchain을 MSVC로 전환한다.
|
||||
- CTest가 0개 테스트를 보고하면 `enable_testing()`과 테스트 등록을 확인한다.
|
||||
- 직접 MSBuild 구성에 test command가 없으면 `msbuild.testCommand` 배열을 추가한다.
|
||||
- timeout 또는 명령 실패 시 Stop 응답의 stage, 안전한 argv 배열, 작업 디렉터리,
|
||||
종료 코드와 출력 tail을 확인하고 해당 명령을 단독으로 다시 실행한다. Harness는
|
||||
별도의 로그 파일을 만들지 않는다.
|
||||
|
||||
@@ -1,602 +1,156 @@
|
||||
# Harness Framework 동작 과정
|
||||
# Harness 실행 흐름
|
||||
|
||||
이 문서는 자연어 요구사항을 받은 뒤 Harness Framework가 계획을 만들고, 독립된
|
||||
Codex 세션에서 Step을 실행하고, MSVC로 C++ 프로젝트를 검증하는 전체 과정을
|
||||
설명한다. 설치 및 설정 예시는 [Harness 운영 가이드](HARNESS.md)를 참고한다.
|
||||
Harness는 승인된 phase를 독립 Codex 세션으로 실행하고 MSVC C++ 제품 규칙을
|
||||
검증한다. 설정 예시는 [HARNESS.md](HARNESS.md), phase 기록 형식은
|
||||
[phase README](phase/README.md)를 참고한다.
|
||||
|
||||
## 1. 핵심 구조
|
||||
## 1. 계획
|
||||
|
||||
Harness Framework는 다음 세 계층으로 구성된다.
|
||||
`AGENTS.md`와 변경에 관련된 제품 문서·코드·테스트를 확인하고 작업을 한 가지 책임의
|
||||
step으로 나눈다. 각 step은 목표와 범위, 관련 파일, 핵심 제약, 실제 Acceptance
|
||||
Criteria를 가진다. 공개 계약이 필요한 경우에만 시그니처를 적는다. 이전 대화에
|
||||
의존하지 않으며 문서 전체를 step 본문에 복사하지 않는다.
|
||||
|
||||
1. **계획 계층**: 요구사항을 분석하고 사용자가 승인할 실행 가능한 Step으로 변환한다.
|
||||
2. **실행 계층**: Step Executor가 Step마다 독립 Codex 세션을 실행하고 상태와 Git
|
||||
커밋을 관리한다.
|
||||
3. **검증 계층**: PreToolUse 훅이 편집 전 정책을 검사하고, Stop 훅이 종료 전 MSVC
|
||||
빌드와 테스트를 실행한다.
|
||||
|
||||
전체 흐름은 다음과 같다.
|
||||
제품 기능의 red-green 작업은 한 step 안에서 끝낸다.
|
||||
|
||||
```text
|
||||
사용자 요구사항
|
||||
↓
|
||||
프로젝트 탐색 및 요구사항 논의
|
||||
↓
|
||||
Step 초안 작성
|
||||
↓
|
||||
사용자 승인
|
||||
↓
|
||||
phases/index.json, task index, stepN.md 생성
|
||||
↓
|
||||
Step Executor 시작
|
||||
↓
|
||||
각 Step을 독립 Codex 세션에서 실행
|
||||
├─ 도구 호출 전: PreToolUse 정책 검사
|
||||
└─ 응답 종료 전: Stop MSVC 빌드·테스트
|
||||
↓
|
||||
성공: 커밋 후 다음 Step
|
||||
실패: 수정 또는 최대 3회 재시도
|
||||
차단: 사용자 개입을 기다리며 중단
|
||||
실패 테스트 작성·확인 → 최소 구현 → 전체 관련 테스트 성공 → step 종료
|
||||
```
|
||||
|
||||
자연어 요구사항만으로 Executor가 자동 시작되지는 않는다. 계획을 사용자가 승인하고
|
||||
phase 파일을 생성한 다음 `scripts/execute.py`를 실행해야 구현 루프가 시작된다.
|
||||
|
||||
## 2. 요구사항 탐색과 구체화
|
||||
|
||||
예를 들어 사용자가 다음 요구사항을 전달했다고 가정한다.
|
||||
|
||||
> CMake 기반 C++20 라이브러리에 `divide()` 함수를 추가하고, 0으로 나누면 예외를
|
||||
> 발생시키며 GoogleTest 테스트를 작성한다.
|
||||
|
||||
계획을 작성하기 전에 다음 자료를 확인한다.
|
||||
|
||||
- `AGENTS.md`
|
||||
- `docs/PRD.md`
|
||||
- `docs/ARCHITECTURE.md`
|
||||
- `docs/ADR.md`
|
||||
- 관련 제품 코드와 테스트
|
||||
- `.harness/config.json`
|
||||
|
||||
이 탐색을 통해 다음 조건을 구체화한다.
|
||||
|
||||
- 사용하는 MSVC toolset과 C++ 표준
|
||||
- CMake 프로젝트인지 Visual Studio solution/project인지
|
||||
- 테스트 프레임워크와 테스트 실행 방법
|
||||
- public header와 implementation의 의존성 방향
|
||||
- 수정할 모듈과 범위 밖 항목
|
||||
- 실행 가능한 Acceptance Criteria 명령
|
||||
|
||||
요구사항에 결정되지 않은 부분이 있으면 구현 전에 사용자와 논의한다. 위 예에서는
|
||||
예외 타입, 정수 또는 부동소수점 연산 여부, public API와 ABI 변경 허용 여부가 이에
|
||||
해당한다.
|
||||
|
||||
## 3. 요구사항을 Step으로 분해
|
||||
|
||||
사용자가 구현 계획 작성을 요청하면 요구사항을 작은 Step으로 나눈다. Step 설계
|
||||
규칙은 [Harness Workflow](../.agents/skills/harness/SKILL.md)에 정의되어 있다.
|
||||
|
||||
각 Step은 다음 조건을 만족해야 한다.
|
||||
|
||||
- 하나의 모듈 또는 명확한 한 가지 책임만 다룬다.
|
||||
- 다른 대화 내용을 참조하지 않아도 실행할 수 있도록 자기완결적으로 작성한다.
|
||||
- 먼저 읽을 문서와 이전 Step의 관련 파일을 명시한다.
|
||||
- 클래스와 함수 시그니처 수준으로 작업 범위를 설명한다.
|
||||
- 실제 실행 가능한 빌드·테스트 명령을 Acceptance Criteria로 사용한다.
|
||||
- 성공, 오류, 사용자 개입 필요 상태의 판정 기준을 적는다.
|
||||
- 범위 밖 기능과 기존 테스트 회귀를 명시적으로 금지한다.
|
||||
|
||||
예시 Step은 다음과 같은 내용을 포함할 수 있다.
|
||||
|
||||
```text
|
||||
Step 0: division-api
|
||||
|
||||
읽어야 할 파일
|
||||
- AGENTS.md
|
||||
- include/calculator.hpp
|
||||
- src/calculator.cpp
|
||||
- tests/calculator_test.cpp
|
||||
|
||||
작업
|
||||
- divide(double lhs, double rhs)의 실패 테스트를 먼저 추가한다.
|
||||
- rhs가 0이면 std::invalid_argument가 발생하도록 최소 구현한다.
|
||||
|
||||
Acceptance Criteria
|
||||
- CMake/MSBuild 빌드가 성공한다.
|
||||
- 전체 테스트가 성공한다.
|
||||
- 새로운 컴파일러 경고가 없다.
|
||||
```
|
||||
|
||||
### TDD와 Step 경계
|
||||
|
||||
프로젝트 규칙은 실패하는 테스트를 먼저 요구하지만 Stop 훅은 Codex가 Step을 종료할
|
||||
때 전체 테스트 성공을 요구한다. 따라서 다음처럼 실패 상태를 Step 사이에 남겨둘 수
|
||||
없다.
|
||||
|
||||
```text
|
||||
Step 0: 실패하는 테스트만 추가하고 종료
|
||||
Step 1: 제품 코드를 구현해 테스트 통과
|
||||
```
|
||||
|
||||
실제 red-green 순서는 하나의 Codex 실행 안에서 완료되어야 한다.
|
||||
|
||||
```text
|
||||
테스트 작성
|
||||
→ 테스트 실패 확인
|
||||
→ 최소 제품 코드 구현
|
||||
→ 테스트 성공 확인
|
||||
→ Step 종료
|
||||
```
|
||||
|
||||
즉, 테스트가 구현보다 먼저 작성되는 순서는 지키되 각 Step은 최종적으로 green
|
||||
상태여야 한다.
|
||||
|
||||
## 4. 사용자 승인 후 생성되는 파일
|
||||
|
||||
Step 초안을 사용자가 승인한 뒤에만 다음 파일을 생성한다.
|
||||
## 2. Phase 파일
|
||||
|
||||
```text
|
||||
phases/
|
||||
├── index.json
|
||||
└── add-division/
|
||||
├── index.json
|
||||
├── step0.md
|
||||
├── step1.md
|
||||
└── ...
|
||||
index.json
|
||||
<phase>/
|
||||
index.json
|
||||
step0.md
|
||||
docs/phase/<phase>/
|
||||
handoff.md
|
||||
step0.md
|
||||
```
|
||||
|
||||
### 4.1 Top-level index
|
||||
|
||||
`phases/index.json`은 여러 task의 상태를 관리한다.
|
||||
`phases/{phase}/index.json`이 실행 상태의 기준이다. `context_files`는 기존 참고 문서,
|
||||
`validation`은 `full` 또는 `docs-only`를 선택한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"phases": [
|
||||
{
|
||||
"dir": "add-division",
|
||||
"status": "pending"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Task index
|
||||
|
||||
`phases/add-division/index.json`은 task 내부 Step의 상태를 관리한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"project": "Calculator",
|
||||
"phase": "add-division",
|
||||
"project": "<제품명 또는 미정>",
|
||||
"phase": "add-parser",
|
||||
"steps": [
|
||||
{
|
||||
"step": 0,
|
||||
"name": "division-api",
|
||||
"status": "pending"
|
||||
"name": "implement-parser",
|
||||
"status": "pending",
|
||||
"context_files": ["docs/ARCHITECTURE.md"],
|
||||
"validation": "full"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
상태별 기록은 다음과 같이 나뉜다.
|
||||
|
||||
| 상태 | Codex가 기록 | Executor가 기록 |
|
||||
|---|---|---|
|
||||
| `completed` | `summary` | `completed_at` |
|
||||
| `error` | `error_message` | `failed_at` |
|
||||
| `blocked` | `blocked_reason` | `blocked_at` |
|
||||
|
||||
Executor는 task의 `created_at`과 Step의 `started_at`도 기록한다. `summary`는 다음
|
||||
독립 Codex 세션이 이전 Step의 핵심 산출물과 결정을 이해할 수 있도록 한 줄로
|
||||
작성한다.
|
||||
|
||||
### 4.3 Step 파일
|
||||
|
||||
각 `stepN.md`에는 다음 내용이 들어간다.
|
||||
|
||||
- 읽어야 할 파일
|
||||
- 작업 범위와 인터페이스
|
||||
- 핵심 동작 및 불변 조건
|
||||
- Acceptance Criteria 명령
|
||||
- 아키텍처·ADR·CRITICAL 규칙 확인 절차
|
||||
- 성공, 오류, 차단 상태 기록 방법
|
||||
- 범위 밖 변경 금지사항
|
||||
|
||||
Step은 독립 Codex 실행의 전체 작업 지시서이므로 이전 대화만 참조하는 표현을 넣지
|
||||
않는다.
|
||||
|
||||
## 5. Step Executor 시작
|
||||
|
||||
계획 파일을 생성한 뒤 다음 명령으로 실행한다.
|
||||
## 3. 읽기 전용 확인
|
||||
|
||||
```powershell
|
||||
python scripts/execute.py add-division
|
||||
python scripts/execute.py <phase> --dry-run
|
||||
```
|
||||
|
||||
완료된 브랜치를 원격 저장소에 자동 push하려면 `--push`를 추가한다.
|
||||
첫 pending step의 현재 주입 경로, 경로별 문자 수와 전체 프롬프트 문자 수를
|
||||
출력한다. Codex, Git, timestamp와 파일을 변경하지 않는다. `--push`와 동시 사용은
|
||||
오류다.
|
||||
|
||||
```powershell
|
||||
python scripts/execute.py add-division --push
|
||||
```
|
||||
## 4. 프롬프트 구성
|
||||
|
||||
[Step Executor](../scripts/execute.py)는 시작할 때 다음 작업을 수행한다.
|
||||
|
||||
1. phase 디렉터리와 task index가 존재하는지 검사한다.
|
||||
2. 이전 실행에서 `error` 또는 `blocked`로 끝난 Step이 있는지 검사한다.
|
||||
3. `feat-{phase-name}` 브랜치를 생성하거나 checkout한다.
|
||||
4. `AGENTS.md`와 `docs/*.md`를 guardrail로 읽는다.
|
||||
5. task의 `created_at`이 없으면 기록한다.
|
||||
6. 첫 번째 `pending` Step부터 순차 실행한다.
|
||||
|
||||
`AGENTS.md`와 모든 `docs/*.md` 내용은 각 Codex 프롬프트에 직접 삽입된다. 따라서
|
||||
이 문서들은 참고 자료가 아니라 실제 실행 입력이다. 서로 충돌하거나 placeholder가
|
||||
남아 있으면 Codex도 그 모순을 입력으로 받는다.
|
||||
|
||||
## 6. Step마다 독립 Codex 세션 실행
|
||||
|
||||
Executor는 각 Step을 다음 형태의 독립 프로세스로 실행한다.
|
||||
각 호출 직전에 index, `context_files`와 handoff를 다시 읽고 다음 순서로 한 번만
|
||||
결합한다.
|
||||
|
||||
```text
|
||||
codex exec
|
||||
--json
|
||||
--sandbox workspace-write
|
||||
--dangerously-bypass-hook-trust
|
||||
--cd <repository-root>
|
||||
-
|
||||
AGENTS.md
|
||||
→ 공통 작업 규칙
|
||||
→ context_files
|
||||
→ 현재 phase handoff 또는 호환 summary
|
||||
→ 직전 실패 요약
|
||||
→ 현재 step
|
||||
```
|
||||
|
||||
Codex에 전달하는 프롬프트는 다음 내용의 조합이다.
|
||||
`context_files` 생략은 추가 문서 없음이다. 잘못된 배열, 없는 파일, UTF-8로 읽을 수
|
||||
없는 파일, 절대 경로, 저장소 밖 경로와 외부를 가리키는 symlink/junction은 Codex나 Git 변경 전에
|
||||
거부한다. 경로 표기와 대소문자만 다른 중복도 한 번만 넣는다.
|
||||
|
||||
```text
|
||||
AGENTS.md와 docs 문서
|
||||
+ 이전에 완료된 Step의 summary
|
||||
+ 이전 시도의 오류(재시도인 경우)
|
||||
+ Executor 공통 작업 규칙
|
||||
+ 현재 stepN.md
|
||||
```
|
||||
handoff가 없을 때만 현재 step보다 앞선 완료 step 중 가장 최근 하나의 summary를
|
||||
최대 1,000자로 전달한다. 이후 step이나 모든 과거 summary를 누적하지 않는다.
|
||||
|
||||
이전 Step의 전체 대화나 Codex 세션은 전달하지 않는다. task index에 기록한
|
||||
`summary`만 다음 Step에 누적한다.
|
||||
## 5. 실행과 상태
|
||||
|
||||
Codex 실행 결과의 exit code, stdout, stderr는 다음 파일에 저장한다.
|
||||
Executor는 `feat-{phase}` branch에서 각 pending step을 독립 `codex exec` 프로세스로
|
||||
실행한다. Codex는 종료 전에 현재 `docs/phase/{phase}/stepN.md`, handoff와 phase index를
|
||||
갱신한다.
|
||||
|
||||
```text
|
||||
phases/{task-name}/step{N}-output.json
|
||||
```
|
||||
|
||||
## 7. 도구 호출 전 PreToolUse 검사
|
||||
|
||||
[`.codex/hooks.json`](../.codex/hooks.json)은 shell 및 파일 편집 도구에
|
||||
[PreToolUse 훅](../scripts/hooks/pre_tool_use.py)을 등록한다. Codex가 실제 명령이나
|
||||
편집을 수행하기 전에 이 훅이 요청을 검사한다.
|
||||
|
||||
### 7.1 위험 명령 차단
|
||||
|
||||
다음 유형의 명령은 요구사항과 관계없이 차단한다.
|
||||
|
||||
- `git reset --hard`
|
||||
- `git push --force` 또는 `--force-with-lease`
|
||||
- `rm -rf`
|
||||
- `Remove-Item -Recurse -Force`
|
||||
- `rmdir /s /q`
|
||||
- `DROP TABLE`
|
||||
|
||||
위험 패턴이 발견되면 훅은 차단 이유를 stderr로 출력하고 종료 코드 2를 반환한다.
|
||||
그러면 해당 도구 호출은 실행되지 않는다.
|
||||
|
||||
### 7.2 C++ TDD 검사
|
||||
|
||||
`apply_patch`, `Edit`, `MultiEdit`, `Write`로 다음 C/C++ 확장자의 파일을 편집하려
|
||||
하면 [TDD 정책](../scripts/msvc_harness/tdd_policy.py)을 검사한다.
|
||||
|
||||
```text
|
||||
.c .cc .cpp .cxx .h .hpp .hxx
|
||||
```
|
||||
|
||||
일반 제품 코드를 수정하려면 대응되는 테스트 파일이 먼저 존재해야 한다. 예를 들어
|
||||
`src/calculator.cpp`의 기본 대응 테스트 이름은 다음과 같다.
|
||||
|
||||
```text
|
||||
calculator_test.cpp
|
||||
calculator_tests.cpp
|
||||
test_calculator.cpp
|
||||
calculator.test.cpp
|
||||
```
|
||||
|
||||
테스트는 다음 위치에서 검색한다.
|
||||
|
||||
- `.harness/config.json`의 `tdd.testRoots`
|
||||
- 제품 파일과 같은 디렉터리 아래 `tests/`
|
||||
- 제품 파일과 같은 디렉터리 아래 `test/`
|
||||
|
||||
다음 파일과 디렉터리는 대응 테스트 존재 검사가 면제된다.
|
||||
|
||||
- 테스트 파일 자체
|
||||
- `main.cpp`
|
||||
- `.harness/build/**`, `build/**`, `out/**`
|
||||
- `cmake-build-*/**`
|
||||
- `third_party/**`, `external/**`, `vendor/**`
|
||||
- `generated/**`
|
||||
- `tdd.exclude`에 추가한 경로
|
||||
|
||||
`tdd.exclude`는 기본 제외 항목을 대체하지 않고 추가한다.
|
||||
|
||||
### 7.3 TDD 검사가 보장하는 범위
|
||||
|
||||
현재 TDD 훅이 직접 보장하는 것은 대응되는 이름의 테스트 파일이 존재한다는
|
||||
사실이다. 다음 항목까지 증명하지는 않는다.
|
||||
|
||||
- 테스트가 이번 요구사항을 실제로 검증하는가
|
||||
- 구현 전에 테스트가 실제로 실패했는가
|
||||
- 테스트의 assertion과 경계 조건이 충분한가
|
||||
- 기존 테스트 파일을 이번 변경과 함께 수정했는가
|
||||
|
||||
또한 shell 명령의 리다이렉션 등으로 C++ 파일을 쓰는 경우 shell 위험 패턴 검사는
|
||||
적용되지만 경로 기반 TDD 검사는 적용되지 않는다. 따라서 이 훅은 완전한 TDD
|
||||
증명기가 아니라 테스트 우선 편집을 유도하는 guardrail이다.
|
||||
|
||||
## 8. Codex 종료 전 Stop 검증
|
||||
|
||||
Codex가 Step 작업을 끝내고 응답을 종료하려 하면
|
||||
[Stop 훅](../scripts/hooks/stop_validation.py)이 실행된다. Stop 훅은 변경 파일만이
|
||||
아니라 발견된 C/C++ 프로젝트 전체를 빌드하고 테스트한다.
|
||||
|
||||
### 8.1 저장소 루트와 재진입 방지
|
||||
|
||||
Stop 훅은 `git rev-parse --show-toplevel`로 프로젝트 루트를 결정한다. Git 저장소를
|
||||
찾을 수 없으면 현재 디렉터리를 사용한다.
|
||||
|
||||
빌드나 테스트의 자식 프로세스에는 `CODEX_STOP_VALIDATION_ACTIVE=1`을 전달한다.
|
||||
같은 훅이 자식 프로세스에서 다시 진입하면 즉시 성공 처리하여 검증 재귀를 막는다.
|
||||
|
||||
### 8.2 설정 로드
|
||||
|
||||
[설정 로더](../scripts/msvc_harness/config.py)는 `.harness/config.json`을 읽는다.
|
||||
파일이 없으면 다음 기본값을 사용한다.
|
||||
|
||||
- `version`: 1
|
||||
- `projectType`: `auto`
|
||||
- CMake source: 저장소 루트
|
||||
- preset 미사용 시 binary directory: `.harness/build`
|
||||
- configuration: `Debug`
|
||||
- platform: `x64`
|
||||
- 테스트 루트: `tests`, `test`
|
||||
- 기본 테스트 이름 패턴 네 개
|
||||
|
||||
설정은 다음 조건을 엄격하게 검사한다.
|
||||
|
||||
- 알 수 없는 필드를 거부한다.
|
||||
- `version`은 숫자 1만 허용한다.
|
||||
- `projectType`은 `auto`, `cmake`, `msbuild`만 허용한다.
|
||||
- 저장소 상대 경로만 허용한다.
|
||||
- 저장소 밖으로 해석되는 경로를 거부한다.
|
||||
- CMake preset을 사용하면 `configurePreset`, `buildPreset`, `testPreset`,
|
||||
`binaryDir`를 모두 요구한다.
|
||||
- 모든 `tdd.testPatterns`에 `{stem}`을 요구한다.
|
||||
|
||||
### 8.3 프로젝트 자동 감지
|
||||
|
||||
[프로젝트 탐색기](../scripts/msvc_harness/discovery.py)는 다음 순서로 프로젝트를
|
||||
선택한다.
|
||||
|
||||
1. `projectType: cmake` 또는 `projectType: msbuild` 명시 설정
|
||||
2. 루트의 `CMakePresets.json`
|
||||
3. 루트의 `CMakeUserPresets.json`
|
||||
4. 루트의 `CMakeLists.txt`
|
||||
5. 루트의 단일 `.sln`
|
||||
6. 루트의 단일 `.vcxproj`
|
||||
|
||||
자동 감지 결과는 다음처럼 처리한다.
|
||||
|
||||
| 저장소 상태 | 결과 |
|
||||
| 상태 | 의미 |
|
||||
|---|---|
|
||||
| CMake metadata가 있음 | CMake 프로젝트 선택 |
|
||||
| 하나의 `.sln` 또는 `.vcxproj`가 있음 | MSBuild 프로젝트 선택 |
|
||||
| 여러 solution/project가 있음 | 설정으로 하나를 지정하라는 오류 |
|
||||
| C/C++ 파일과 build metadata가 모두 없음 | 검증할 프로젝트가 없으므로 통과 |
|
||||
| C/C++ 파일은 있지만 build metadata가 없음 | orphan C++ 프로젝트 오류 |
|
||||
| `completed` | exit code 0일 때만 성공, executor가 timestamp와 commit 처리 |
|
||||
| `error` | 자동 시도 예산 소진, 이유와 timestamp 기록 후 중단 |
|
||||
| `blocked` | 사용자 또는 외부 환경 조치 필요, 추가 호출 없이 중단 |
|
||||
|
||||
### 8.4 MSVC 도구 탐색
|
||||
프로세스가 nonzero로 끝나면 index에 `completed`가 있어도 성공으로 처리하지 않는다.
|
||||
|
||||
[도구 탐색기](../scripts/msvc_harness/toolchain.py)는 `vswhere.exe`로 다음을
|
||||
확인한다.
|
||||
## 6. 재시도와 로그
|
||||
|
||||
- Visual Studio 설치 경로
|
||||
- Desktop development with C++ workload
|
||||
- `MSBuild.exe`
|
||||
Executor의 호출 예산은 최초 호출을 포함해 총 3회다. 한 Codex 세션 안의 일반적인
|
||||
red-green 수정 횟수를 별도로 3회로 제한하지 않는다.
|
||||
|
||||
CMake 프로젝트에서는 다음 우선순위로 CMake와 CTest를 선택한다.
|
||||
재시도 입력의 오류 본문은 총 4,000자로 제한한다. 앞 1,500자와 끝부분 최대 2,500자를
|
||||
남기고 중간 생략 marker를 제한 안에 포함한다. 전체 stdout/stderr 결과는
|
||||
`phases/{phase}/stepN-output.json`에 저장하지만 다음 시도에서 같은 파일을 덮어쓴다.
|
||||
장기 보관할 시행착오는 step 기록에 요약한다. timeout도 같은 호출 예산의 실패이며
|
||||
인증·CLI 옵션 같은 환경 오류는 즉시 중단한다.
|
||||
|
||||
1. PATH에서 발견한 독립 `cmake.exe`와 `ctest.exe`
|
||||
2. Visual Studio에 번들된 CMake와 CTest
|
||||
## 7. 편집 전 정책
|
||||
|
||||
따라서 새로 설치한 CMake의 `bin` 디렉터리가 PATH에 반영되어 있으면 독립 CMake를
|
||||
우선 사용한다.
|
||||
PreToolUse 훅은 위험한 삭제, 강제 push, hard reset과 SQL 파괴 명령을 차단한다.
|
||||
C/C++ 소스·header 편집에는 대응 테스트 파일 존재 여부를 검사한다. 테스트 파일,
|
||||
`main.cpp`, build output, 외부 의존성, generated 경로와 설정된 제외 패턴에는 기존
|
||||
면제 규칙을 적용한다.
|
||||
|
||||
## 9. 빌드 시스템별 검증 계획
|
||||
기본 basename 테스트 패턴은 같은 검사 호출 안에서 test root별 목록을 재사용한다.
|
||||
경로를 포함한 복합 패턴은 링크·상대 경로 호환성을 위해 기존 rglob을 사용하므로 패턴별
|
||||
순회가 발생할 수 있다. C++ 파일 탐색은
|
||||
제외 디렉터리의 불필요한 방문을 줄인다. 다음 호출에는 파일 추가·삭제가 즉시 반영된다.
|
||||
C++ 파일 탐색은 제외 폴더 내부의 symlink/junction도 건너뛴다. 기존에는 이 링크를 통해
|
||||
비제외 소스나 중복 경로를 찾을 수 있었으나 이제는 제외 폴더의 링크로만 접근 가능한
|
||||
소스를 자동 감지하지 않는다. 실제 제품 source 경로와 build metadata를 지정한다.
|
||||
그 외 대응 테스트 및 프로젝트 판정은 유지하며 실제 시간 개선율은 별도 측정 전까지
|
||||
미검증이다.
|
||||
|
||||
### 9.1 CMake preset 미사용
|
||||
## 8. 종료 전 검증
|
||||
|
||||
[CMake adapter](../scripts/msvc_harness/adapters/cmake.py)는 다음 검증 계획을 만든다.
|
||||
`validation` 생략 또는 `full`이면 Stop 훅이 발견한 C++ 제품 전체를 검증한다.
|
||||
|
||||
```powershell
|
||||
cmake -S <source> -B .harness/build -A x64
|
||||
cmake --build .harness/build --config Debug
|
||||
ctest --test-dir .harness/build -C Debug --show-only=json-v1
|
||||
ctest --test-dir .harness/build -C Debug --output-on-failure
|
||||
```
|
||||
- CMake: configure, build, CTest 발견, CTest 실행과 MSVC compiler metadata 확인
|
||||
- MSBuild: build 후 `.harness/config.json`의 `msbuild.testCommand` 실행
|
||||
- metadata 없음: 제외되지 않은 C++ 파일이 하나라도 있으면 orphan 오류
|
||||
|
||||
명령 성공 외에 다음 결과도 검사한다.
|
||||
`docs-only`이면 executor가 현재 phase를 자식 프로세스 환경으로 전달하고 Stop 훅이
|
||||
실제 Git 변경을 다시 검사한다. 루트 `AGENTS.md`, `docs/**/*.md`, 현재 phase의
|
||||
`index.json`과 `stepN-output.json`만 허용하며 실제 문서 변경이 있어야 생략한다.
|
||||
rename은 원래 경로와 새 경로를 모두 검사한다. Git 실패, 빈 변경, bookkeeping만 있는
|
||||
변경, untracked C++ 또는 다른 파일이 하나라도 있으면 전체 검증으로 돌아간다.
|
||||
symlink나 junction으로 해석되는 변경 경로도 보수적으로 전체 검증한다.
|
||||
|
||||
- 생성된 CMake compiler metadata의 `CMAKE_CXX_COMPILER_ID`가 `MSVC`인가
|
||||
- CTest JSON에 한 개 이상의 테스트가 있는가
|
||||
## 9. Commit, 완료와 재개
|
||||
|
||||
따라서 빌드가 성공해도 MinGW 등 다른 컴파일러를 사용했거나 CTest가 테스트를 한
|
||||
개도 발견하지 못하면 실패한다.
|
||||
step 성공 시 executor는 제품 변경과 metadata를 분리해 commit한다. 모든 step이
|
||||
성공하면 phase와 top-level 상태를 `completed`로 바꾸고 `--push`가 있으면
|
||||
`origin/feat-{phase}`에 push한다. 실행기는 광범위한 staging을 할 수 있으므로 깨끗한
|
||||
전용 worktree에서 실행한다.
|
||||
|
||||
### 9.2 CMake preset 사용
|
||||
재개 절차:
|
||||
|
||||
`.harness/config.json`에 preset을 완전히 지정하면 다음 형태로 실행한다.
|
||||
1. `error` 또는 `blocked` 원인을 해결한다.
|
||||
2. 해당 step status를 `pending`으로 바꾼다.
|
||||
3. `error_message`/`failed_at` 또는 `blocked_reason`/`blocked_at`을 제거한다.
|
||||
4. handoff, 직전 원본 출력과 index를 확인한 뒤 다시 실행한다.
|
||||
|
||||
```powershell
|
||||
cmake --preset <configurePreset>
|
||||
cmake --build --preset <buildPreset>
|
||||
ctest --preset <testPreset> --show-only=json-v1
|
||||
ctest --preset <testPreset> --output-on-failure
|
||||
```
|
||||
|
||||
이 경우 모든 명령은 `cmake.sourceDir`에서 실행하고 compiler metadata 검사는 설정한
|
||||
`binaryDir`에서 수행한다.
|
||||
|
||||
### 9.3 직접 MSBuild
|
||||
|
||||
[MSBuild adapter](../scripts/msvc_harness/adapters/msbuild.py)는 다음 순서로 실행한다.
|
||||
|
||||
```powershell
|
||||
MSBuild.exe <solution-or-vcxproj> /m /nologo `
|
||||
/p:Configuration=<configuration> `
|
||||
/p:Platform=<platform>
|
||||
|
||||
<msbuild.testCommand>
|
||||
```
|
||||
|
||||
직접 MSBuild 프로젝트는 표준 테스트 탐색 명령이 없으므로
|
||||
`.harness/config.json`의 `msbuild.testCommand`가 반드시 필요하다. 이 값이 없으면
|
||||
Stop 검증이 실패한다.
|
||||
|
||||
## 10. 명령 실행 안전성과 제한시간
|
||||
|
||||
[검증 실행기](../scripts/msvc_harness/process.py)는 다음 안전 규칙을 적용한다.
|
||||
|
||||
- 명령을 shell 문자열이 아닌 argv 배열로 실행한다.
|
||||
- `shell=False`를 사용한다.
|
||||
- 각 명령의 working directory가 저장소 내부인지 검사한다.
|
||||
- 명령별 제한시간과 Stop 전체 제한시간 중 더 짧은 값을 적용한다.
|
||||
- 종료 코드가 0이 아니면 즉시 해당 stage를 실패 처리한다.
|
||||
|
||||
Stop 훅의 전체 제한시간은 저장소 탐색, toolchain 탐색, configure, build, test discovery,
|
||||
test를 모두 포함해 1,800초다. `.codex/hooks.json`의 Stop command timeout도 1,800초다.
|
||||
|
||||
실패 메시지에는 다음 진단 정보를 포함한다.
|
||||
|
||||
- 실패 stage
|
||||
- 안전하게 표현한 argv 배열
|
||||
- working directory
|
||||
- 종료 코드
|
||||
- stdout과 stderr의 마지막 8,000자
|
||||
|
||||
Harness는 별도 빌드 로그 파일을 생성하지 않는다.
|
||||
|
||||
## 11. 성공, 실패, 차단 처리
|
||||
|
||||
### 11.1 Stop 검증 성공
|
||||
|
||||
빌드와 테스트가 모두 성공하면 Stop 훅은 출력 없이 종료한다. Codex가 정상 종료하면
|
||||
Executor가 task index를 다시 읽는다.
|
||||
|
||||
Codex가 Step을 다음처럼 기록한 경우:
|
||||
|
||||
```json
|
||||
{
|
||||
"step": 0,
|
||||
"name": "division-api",
|
||||
"status": "completed",
|
||||
"summary": "divide API와 0 나누기 테스트를 추가함"
|
||||
}
|
||||
```
|
||||
|
||||
Executor는 `completed_at`을 기록하고 변경사항을 커밋한 뒤 다음 `pending` Step을
|
||||
실행한다.
|
||||
|
||||
### 11.2 Stop 검증 실패
|
||||
|
||||
Stop 훅은 Codex hook protocol에 따라 다음 형태의 응답을 출력한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"continue": false,
|
||||
"stopReason": "build failed ...",
|
||||
"systemMessage": "build failed ..."
|
||||
}
|
||||
```
|
||||
|
||||
Codex 프로세스에 대한 훅 자체의 종료 코드는 0이지만 `continue: false`가 Codex의
|
||||
응답 종료를 막는다. Codex는 같은 세션에서 오류를 확인하고 수정을 계속한다.
|
||||
|
||||
### 11.3 Executor 재시도
|
||||
|
||||
Codex 프로세스가 끝났는데 Step 상태가 `completed` 또는 `blocked`가 아니면 Executor가
|
||||
새 Codex 세션으로 재시도한다.
|
||||
|
||||
```text
|
||||
첫 번째 시도 실패
|
||||
→ 오류를 다음 프롬프트에 삽입
|
||||
→ 두 번째 독립 Codex 실행
|
||||
→ 다시 실패하면 세 번째 독립 Codex 실행
|
||||
→ 세 번째도 실패하면 error 기록 후 종료
|
||||
```
|
||||
|
||||
즉, 실패 복구에는 두 층이 있다.
|
||||
|
||||
1. Stop 훅이 같은 Codex 세션에서 수정하도록 요구한다.
|
||||
2. 세션 자체가 성공하지 못하면 Executor가 새 세션으로 최대 3회 재시도한다.
|
||||
|
||||
### 11.4 사용자 개입 필요
|
||||
|
||||
인증, API 키, 수동 설치처럼 Codex가 자동으로 해결할 수 없는 문제가 있으면 Step을
|
||||
`blocked`로 기록한다. Executor는 `blocked_at`과 top-level 상태를 갱신하고 종료 코드
|
||||
2로 중단한다.
|
||||
|
||||
재개하려면 원인을 해결하고 해당 Step을 `pending`으로 되돌린 뒤
|
||||
`blocked_reason`을 제거하고 다시 실행한다. `error`도 같은 방식으로 `pending`으로
|
||||
되돌리고 `error_message`를 제거한 뒤 재실행한다.
|
||||
|
||||
## 12. Git 커밋과 phase 완료
|
||||
|
||||
Step이 성공하면 제품 변경과 Harness metadata를 분리해 다음 형식으로 커밋한다.
|
||||
|
||||
```text
|
||||
feat(add-division): step 0 — division-api
|
||||
chore(add-division): step 0 output
|
||||
```
|
||||
|
||||
모든 Step이 완료되면 Executor는 다음 작업을 수행한다.
|
||||
|
||||
- task의 `completed_at` 기록
|
||||
- `phases/index.json`의 task 상태를 `completed`로 변경
|
||||
- 최종 metadata 커밋
|
||||
- `--push` 사용 시 `origin/feat-{phase-name}`으로 push
|
||||
|
||||
커밋 과정은 `git add -A`를 사용한다. 실행 전에 작업 트리에 관련 없는 사용자
|
||||
변경사항이 남아 있으면 그 변경도 Step 커밋에 포함될 수 있다. 따라서 깨끗한
|
||||
worktree 또는 별도 Git worktree에서 실행하는 것이 안전하다.
|
||||
|
||||
## 13. 요구사항 종류별 동작
|
||||
|
||||
| 받은 요구사항 또는 변경 | PreToolUse 동작 | Stop 동작 |
|
||||
|---|---|---|
|
||||
| 새 C++ 제품 파일 추가 | 대응 테스트가 먼저 없으면 차단 | 전체 빌드·테스트 |
|
||||
| 기존 C++ 구현 또는 header 수정 | 대응 테스트 파일 존재 여부 검사 | 전체 빌드·테스트 |
|
||||
| 테스트 파일 추가 | TDD 차단 없이 허용 | 모든 테스트가 성공해야 종료 |
|
||||
| `main.cpp` 수정 | TDD 대응 테스트 검사 면제 | 전체 빌드·테스트 |
|
||||
| 문서, JSON, Python 수정 | C++ TDD 검사 없음 | C++ 프로젝트가 있으면 전체 검증 |
|
||||
| 위험한 Git 또는 삭제 명령 | 즉시 차단 | 도달하지 않음 |
|
||||
| C++ 파일은 있지만 build metadata 없음 | 편집은 허용될 수 있음 | orphan 프로젝트 오류 |
|
||||
| 직접 MSBuild인데 `testCommand` 없음 | 편집은 허용될 수 있음 | 설정 오류로 종료 차단 |
|
||||
| C/C++가 전혀 없는 저장소 | 관련 편집 검사 없음 | 검증할 프로젝트가 없어 통과 |
|
||||
|
||||
## 14. 적용 전 준비사항
|
||||
|
||||
이 저장소는 대상 C++ 프로젝트에 맞게 채워 사용하는 템플릿이다. 실행 전 다음을
|
||||
확인한다.
|
||||
|
||||
1. `AGENTS.md`의 프로젝트명, toolset, C++ 표준, 테스트 프레임워크, CRITICAL 규칙을
|
||||
실제 값으로 교체한다.
|
||||
2. `docs/PRD.md`, `docs/ARCHITECTURE.md`, `docs/ADR.md`의 placeholder와 예시를 실제
|
||||
프로젝트 정보로 교체한다.
|
||||
3. `.harness/config.example.json`을 참고해 `.harness/config.json`을 만든다.
|
||||
4. CMake 또는 MSBuild metadata와 테스트 실행 방법을 확인한다.
|
||||
5. `phases/`가 없다면 요구사항 논의와 계획 승인을 거쳐 task 파일을 먼저 만든다.
|
||||
6. Executor 실행 전에 Git working tree가 깨끗한지 확인한다.
|
||||
|
||||
특히 `AGENTS.md`와 `docs/*.md`는 각 Codex 실행에 그대로 주입된다. C++ 프로젝트에서
|
||||
TypeScript 예시나 미완성 placeholder가 남아 있으면 실제 작업 지시와 충돌할 수 있다.
|
||||
강제 종료로 handoff가 낡았을 수 있으므로 index 상태를 우선한다. 이 저장소에는 아직
|
||||
실제 C++ 제품 소스와 확정된 build 설정이 없어 일반 제품 MSVC build 성공은
|
||||
검증되지 않았다.
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
# PRD: {프로젝트명}
|
||||
# C++ 제품 PRD
|
||||
|
||||
Harness 운영 요구사항은 이 문서의 제품 요구사항에 포함하지 않는다.
|
||||
|
||||
## 목표
|
||||
{이 프로젝트가 해결하려는 문제를 한 줄로 요약}
|
||||
|
||||
미정 — 제품 요구사항 정의 시 확정.
|
||||
|
||||
## 사용자
|
||||
{누가 이 제품을 쓰는지}
|
||||
|
||||
미정 — 제품 요구사항 정의 시 확정.
|
||||
|
||||
## 핵심 기능
|
||||
1. {기능 1}
|
||||
2. {기능 2}
|
||||
3. {기능 3}
|
||||
|
||||
## MVP 제외 사항
|
||||
- {안 만들 것 1}
|
||||
- {안 만들 것 2}
|
||||
- {안 만들 것 3}
|
||||
미정 — 제품 요구사항 정의 시 확정.
|
||||
|
||||
## 디자인
|
||||
- {디자인 방향 (예: 다크모드 고정, 미니멀)}
|
||||
- {색상 (예: 무채색 + 포인트 1가지)}
|
||||
## 제약
|
||||
|
||||
- MSVC 기반 C++ 제품으로 개발한다.
|
||||
- MSVC toolset, C++ 표준, 지원 플랫폼과 성능·보안·호환성 조건은 미정이다.
|
||||
- public header와 implementation의 의존성 방향을 유지한다.
|
||||
- 새 기능과 버그 수정은 실패하는 테스트를 먼저 확인한다.
|
||||
- 새 빌드 경고를 추가하지 않는다.
|
||||
|
||||
## 완료 조건
|
||||
|
||||
미정 — 제품 기능별 실행 가능한 build/test 명령과 수용 조건을 정의할 때 확정.
|
||||
|
||||
## 제외 범위
|
||||
|
||||
- Harness 실행기, 훅, phase 관리와 문맥 경량화는 개발 도구의 책임이며 제품 기능이
|
||||
아니다.
|
||||
- 구체적인 제품 기능, API, UI와 배포 범위는 요구사항이 정해질 때까지 확정하지 않는다.
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
@@ -3,7 +3,7 @@
|
||||
Harness Step Executor — phase 내 step을 순차 실행하고 자가 교정한다.
|
||||
|
||||
Usage:
|
||||
python scripts/execute.py <phase-dir> [--push]
|
||||
python scripts/execute.py <phase-dir> [--push | --dry-run]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -16,7 +16,7 @@ import threading
|
||||
import time
|
||||
import types
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PureWindowsPath
|
||||
from typing import Optional
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -26,6 +26,10 @@ class CodexEnvironmentError(RuntimeError):
|
||||
"""재시도로 해결할 수 없는 Codex CLI 환경 오류."""
|
||||
|
||||
|
||||
class ContextError(ValueError):
|
||||
"""Step 실행 전에 수정해야 하는 문서/설정 오류."""
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def progress_indicator(label: str):
|
||||
"""터미널 진행 표시기. with 문으로 사용하며 .elapsed 로 경과 시간을 읽는다."""
|
||||
@@ -63,9 +67,14 @@ class StepExecutor:
|
||||
TZ = timezone(timedelta(hours=9))
|
||||
|
||||
def __init__(self, phase_dir_name: str, *, auto_push: bool = False):
|
||||
if (not phase_dir_name or phase_dir_name in {".", ".."}
|
||||
or any(c in phase_dir_name for c in "/\\:")):
|
||||
raise ContextError("phase must be a directory name under phases/")
|
||||
self._root = str(ROOT)
|
||||
self._phases_dir = ROOT / "phases"
|
||||
self._phase_dir = self._phases_dir / phase_dir_name
|
||||
if self._phase_dir.resolve().parent != self._phases_dir.resolve():
|
||||
raise ContextError("phase resolves outside phases/")
|
||||
self._phase_dir_name = phase_dir_name
|
||||
self._top_index_file = self._phases_dir / "index.json"
|
||||
self._auto_push = auto_push
|
||||
@@ -84,13 +93,27 @@ class StepExecutor:
|
||||
self._phase_name = idx.get("phase", phase_dir_name)
|
||||
self._total = len(idx["steps"])
|
||||
|
||||
def run(self):
|
||||
def run(self, *, dry_run: bool = False):
|
||||
if dry_run and self._auto_push:
|
||||
raise ContextError("--dry-run and --push cannot be combined")
|
||||
self._print_header()
|
||||
self._check_blockers()
|
||||
pending = [s for s in self._read_json(self._index_file)["steps"]
|
||||
if s["status"] == "pending"]
|
||||
if dry_run:
|
||||
if not pending:
|
||||
print("No pending steps.")
|
||||
return
|
||||
sections = self._prompt_sections(pending[0])
|
||||
for label, body in sections:
|
||||
print(f" {label}: {len(body)} characters")
|
||||
print(f" Total prompt: {len(self._render_prompt(sections))} characters")
|
||||
return
|
||||
for step in pending:
|
||||
self._build_prompt(step)
|
||||
self._checkout_branch()
|
||||
guardrails = self._load_guardrails()
|
||||
self._ensure_created_at()
|
||||
self._execute_all_steps(guardrails)
|
||||
self._execute_all_steps()
|
||||
self._finalize()
|
||||
|
||||
# --- timestamps ---
|
||||
@@ -112,7 +135,8 @@ class StepExecutor:
|
||||
|
||||
def _run_git(self, *args) -> subprocess.CompletedProcess:
|
||||
cmd = ["git"] + list(args)
|
||||
return subprocess.run(cmd, cwd=self._root, capture_output=True, text=True)
|
||||
return subprocess.run(cmd, cwd=self._root, capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace")
|
||||
|
||||
def _checkout_branch(self):
|
||||
branch = f"feat-{self._phase_name}"
|
||||
@@ -178,69 +202,118 @@ class StepExecutor:
|
||||
|
||||
# --- guardrails & context ---
|
||||
|
||||
def _load_guardrails(self) -> str:
|
||||
sections = []
|
||||
agents_md = ROOT / "AGENTS.md"
|
||||
if agents_md.exists():
|
||||
sections.append(
|
||||
"## 프로젝트 규칙 (AGENTS.md)\n\n"
|
||||
f"{agents_md.read_text(encoding='utf-8')}"
|
||||
)
|
||||
docs_dir = ROOT / "docs"
|
||||
if docs_dir.is_dir():
|
||||
for doc in sorted(docs_dir.glob("*.md")):
|
||||
sections.append(
|
||||
f"## {doc.stem}\n\n{doc.read_text(encoding='utf-8')}"
|
||||
)
|
||||
return "\n\n---\n\n".join(sections) if sections else ""
|
||||
def _document_path(self, raw: str) -> Path:
|
||||
if (not isinstance(raw, str) or not raw.strip()
|
||||
or PureWindowsPath(raw).drive or PureWindowsPath(raw).root
|
||||
or any(c in raw for c in "*?[]:")):
|
||||
raise ContextError(f"context file must be a repository-relative file: {raw!r}")
|
||||
root = Path(self._root).resolve()
|
||||
path = (root / raw.replace("\\", "/")).resolve()
|
||||
if not path.is_relative_to(root):
|
||||
raise ContextError(f"context file resolves outside repository: {raw}")
|
||||
return path
|
||||
|
||||
def _read_document(self, path: Path) -> tuple[str, str]:
|
||||
label = path.relative_to(Path(self._root).resolve()).as_posix()
|
||||
try:
|
||||
return label, path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as exc:
|
||||
raise ContextError(f"cannot read UTF-8 document {label}: {exc}") from exc
|
||||
|
||||
def _prompt_sections(self, step: dict, prev_error: Optional[str] = None) -> list:
|
||||
self._validation_mode(step)
|
||||
paths = step.get("context_files", [])
|
||||
if not isinstance(paths, list):
|
||||
raise ContextError("context_files must be a string array")
|
||||
step_path = self._document_path(f"phases/{self._phase_dir_name}/step{step['step']}.md")
|
||||
agents = self._document_path("AGENTS.md")
|
||||
handoff = self._document_path(f"docs/phase/{self._phase_dir_name}/handoff.md")
|
||||
seen = {step_path, agents, handoff}
|
||||
sections = [self._read_document(agents)] if agents.exists() else []
|
||||
sections.append(("작업 규칙", self._work_rules(step)))
|
||||
for raw in paths:
|
||||
path = self._document_path(raw)
|
||||
if not path.is_file():
|
||||
raise ContextError(f"context file not found: {raw}")
|
||||
if path not in seen:
|
||||
sections.append(self._read_document(path))
|
||||
seen.add(path)
|
||||
if handoff.exists():
|
||||
sections.append(self._read_document(handoff))
|
||||
else:
|
||||
summary = self._build_step_context(self._read_json(self._index_file), step["step"])
|
||||
if summary:
|
||||
sections.append((f"이전 step 요약 (phases/{self._phase_dir_name}/index.json)", summary))
|
||||
if prev_error:
|
||||
sections.append(("이전 시도 실패", self._bounded_error(prev_error)))
|
||||
sections.append(("직전 원본 로그",
|
||||
f"phases/{self._phase_dir_name}/step{step['step']}-output.json"))
|
||||
sections.append(self._read_document(step_path))
|
||||
return sections
|
||||
|
||||
@staticmethod
|
||||
def _build_step_context(index: dict) -> str:
|
||||
lines = [
|
||||
f"- Step {s['step']} ({s['name']}): {s['summary']}"
|
||||
for s in index["steps"]
|
||||
if s["status"] == "completed" and s.get("summary")
|
||||
]
|
||||
if not lines:
|
||||
return ""
|
||||
return "## 이전 Step 산출물\n\n" + "\n".join(lines) + "\n\n"
|
||||
def _render_prompt(sections: list) -> str:
|
||||
return "\n\n---\n\n".join(f"## {label}\n\n{body}" for label, body in sections)
|
||||
|
||||
def _build_preamble(self, guardrails: str, step_context: str,
|
||||
prev_error: Optional[str] = None) -> str:
|
||||
retry_section = ""
|
||||
if prev_error:
|
||||
retry_section = (
|
||||
f"\n## ⚠ 이전 시도 실패 — 아래 에러를 반드시 참고하여 수정하라\n\n"
|
||||
f"{prev_error}\n\n---\n\n"
|
||||
)
|
||||
def _build_prompt(self, step: dict, prev_error: Optional[str] = None) -> str:
|
||||
return self._render_prompt(self._prompt_sections(step, prev_error))
|
||||
|
||||
@staticmethod
|
||||
def _validation_mode(step: dict) -> str:
|
||||
mode = step.get("validation", "full")
|
||||
if mode not in ("full", "docs-only"):
|
||||
raise ContextError("validation must be full or docs-only")
|
||||
return mode
|
||||
|
||||
@staticmethod
|
||||
def _bounded_error(error: str) -> str:
|
||||
if len(error) <= 4000:
|
||||
return error
|
||||
marker = "\n[중간 생략; 원본 로그 참조]\n"
|
||||
return error[:1500] + marker + error[-(2500 - len(marker)):]
|
||||
|
||||
@staticmethod
|
||||
def _build_step_context(index: dict, step_num: int) -> str:
|
||||
prior = [s for s in index["steps"]
|
||||
if s["step"] < step_num and s["status"] == "completed"]
|
||||
if not prior:
|
||||
return ""
|
||||
step = max(prior, key=lambda s: s["step"])
|
||||
summary = step.get("summary", "")
|
||||
if not summary:
|
||||
return ""
|
||||
if len(summary) > 1000:
|
||||
summary = summary[:980] + "\n[이후 생략; index 참조]"
|
||||
return f"Step {step['step']} ({step['name']}): {summary}"
|
||||
|
||||
def _work_rules(self, step: dict) -> str:
|
||||
notes = f"docs/phase/{self._phase_dir_name}"
|
||||
return (
|
||||
f"당신은 {self._project} 프로젝트의 개발자입니다. 아래 step을 수행하세요.\n\n"
|
||||
f"{guardrails}\n\n---\n\n"
|
||||
f"{step_context}{retry_section}"
|
||||
f"## 작업 규칙\n\n"
|
||||
f"1. 이전 step에서 작성된 코드를 확인하고 일관성을 유지하라.\n"
|
||||
f"2. 이 step에 명시된 작업만 수행하라. 추가 기능이나 파일을 만들지 마라.\n"
|
||||
f"1. 지정한 문서와 관련 코드를 읽고, 추가 자료는 필요할 때만 읽어라.\n"
|
||||
f"2. 현재 step 범위만 수행하라. 아래 인계·step 기록은 허용된 산출물이다.\n"
|
||||
f"3. 기존 테스트를 깨뜨리지 마라.\n"
|
||||
f"4. AC(Acceptance Criteria) 검증을 직접 실행하라.\n"
|
||||
f"5. /phases/{self._phase_dir_name}/index.json의 해당 step status를 업데이트하라:\n"
|
||||
f"5. 성공·실패·차단을 보고하기 전에 {notes}/handoff.md와 {notes}/step{step['step']}.md를 갱신하라.\n"
|
||||
f" handoff는 현재 상황, 유효한 결정과 이유, 미해결 문제, 검증 명령·결과, 다음 시작점과 상세 기록 링크를 약 60줄로 유지하라.\n"
|
||||
f" step 기록에는 변경 파일, 결정, 검증, 실패한 접근과 이유를 적고 원본 로그를 복사하지 마라.\n"
|
||||
f"6. phases/{self._phase_dir_name}/index.json의 해당 step status를 업데이트하라:\n"
|
||||
f" - AC 통과 → \"completed\" + \"summary\" 필드에 이 step의 산출물을 한 줄로 요약\n"
|
||||
f" - {self.MAX_RETRIES}회 수정 시도 후에도 실패 → \"error\" + \"error_message\" 기록\n"
|
||||
f" - 이번 세션에서 해결하지 못한 실패 → \"error\" + \"error_message\"에 원인·시도한 수정 기록\n"
|
||||
f" - 사용자 개입이 필요한 경우 (API 키, 인증, 수동 설정 등) → \"blocked\" + \"blocked_reason\" 기록 후 즉시 중단\n"
|
||||
f"6. 변경사항을 직접 커밋하지 마라. Git 커밋과 timestamp는 실행기가 처리한다.\n\n"
|
||||
f"---\n\n"
|
||||
f" 새 세션 재시도는 실행기가 최초 호출 포함 최대 {self.MAX_RETRIES}회 관리한다.\n"
|
||||
f" 상태가 인계와 다르면 index와 실제 변경·로그를 확인하라.\n"
|
||||
f"7. 변경사항을 직접 커밋하지 마라. Git 커밋과 timestamp는 실행기가 처리한다."
|
||||
)
|
||||
|
||||
# --- Codex 호출 ---
|
||||
|
||||
def _invoke_codex(self, step: dict, preamble: str) -> dict:
|
||||
def _invoke_codex(self, step: dict, prompt: str) -> dict:
|
||||
step_num, step_name = step["step"], step["name"]
|
||||
step_file = self._phase_dir / f"step{step_num}.md"
|
||||
|
||||
if not step_file.exists():
|
||||
print(f" ERROR: {step_file} not found")
|
||||
sys.exit(1)
|
||||
|
||||
prompt = preamble + step_file.read_text(encoding="utf-8")
|
||||
child_env = os.environ.copy()
|
||||
child_env.pop("HARNESS_DOCS_ONLY_PHASE", None)
|
||||
if self._validation_mode(step) == "docs-only":
|
||||
child_env["HARNESS_DOCS_ONLY_PHASE"] = f"phases/{self._phase_dir_name}"
|
||||
command = [
|
||||
"codex",
|
||||
"exec",
|
||||
@@ -257,14 +330,27 @@ class StepExecutor:
|
||||
command,
|
||||
cwd=self._root,
|
||||
input=prompt,
|
||||
env=child_env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=1800,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise CodexEnvironmentError(
|
||||
"Codex CLI를 찾을 수 없습니다. codex를 설치하고 PATH를 확인하세요."
|
||||
) from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
stdout = exc.stdout or ""
|
||||
stderr = exc.stderr or ""
|
||||
if isinstance(stdout, bytes):
|
||||
stdout = stdout.decode("utf-8", errors="replace")
|
||||
if isinstance(stderr, bytes):
|
||||
stderr = stderr.decode("utf-8", errors="replace")
|
||||
result = subprocess.CompletedProcess(
|
||||
command, 124, stdout, f"Codex timed out after 1800 seconds\n{stderr}",
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"\n WARN: Codex가 비정상 종료됨 (code {result.returncode})")
|
||||
@@ -333,7 +419,7 @@ class StepExecutor:
|
||||
|
||||
# --- 실행 루프 ---
|
||||
|
||||
def _execute_single_step(self, step: dict, guardrails: str) -> bool:
|
||||
def _execute_single_step(self, step: dict) -> bool:
|
||||
"""단일 step 실행 (재시도 포함). 완료되면 True, 실패/차단이면 False."""
|
||||
step_num, step_name = step["step"], step["name"]
|
||||
done = sum(1 for s in self._read_json(self._index_file)["steps"] if s["status"] == "completed")
|
||||
@@ -341,26 +427,33 @@ class StepExecutor:
|
||||
|
||||
for attempt in range(1, self.MAX_RETRIES + 1):
|
||||
index = self._read_json(self._index_file)
|
||||
step_context = self._build_step_context(index)
|
||||
preamble = self._build_preamble(guardrails, step_context, prev_error)
|
||||
step = next(s for s in index["steps"] if s["step"] == step_num)
|
||||
prompt = self._build_prompt(step, prev_error)
|
||||
|
||||
tag = f"Step {step_num}/{self._total - 1} ({done} done): {step_name}"
|
||||
if attempt > 1:
|
||||
tag += f" [retry {attempt}/{self.MAX_RETRIES}]"
|
||||
|
||||
with progress_indicator(tag) as pi:
|
||||
output = self._invoke_codex(step, preamble)
|
||||
output = self._invoke_codex(step, prompt)
|
||||
elapsed = int(pi.elapsed)
|
||||
|
||||
index = self._read_json(self._index_file)
|
||||
if output["exitCode"] != 0:
|
||||
for s in index["steps"]:
|
||||
if s["step"] == step_num and s["status"] == "completed":
|
||||
s["status"] = "error"
|
||||
s.pop("completed_at", None)
|
||||
s.pop("summary", None)
|
||||
self._write_json(self._index_file, index)
|
||||
environment_failure = self._codex_environment_failure(output)
|
||||
if environment_failure:
|
||||
raise CodexEnvironmentError(environment_failure)
|
||||
|
||||
index = self._read_json(self._index_file)
|
||||
status = next((s.get("status", "pending") for s in index["steps"] if s["step"] == step_num), "pending")
|
||||
ts = self._stamp()
|
||||
|
||||
if status == "completed":
|
||||
if status == "completed" and output["exitCode"] == 0:
|
||||
for s in index["steps"]:
|
||||
if s["step"] == step_num:
|
||||
s["completed_at"] = ts
|
||||
@@ -380,23 +473,16 @@ class StepExecutor:
|
||||
self._update_top_index("blocked")
|
||||
sys.exit(2)
|
||||
|
||||
runtime_error = ""
|
||||
if output["exitCode"] != 0:
|
||||
runtime_error = (
|
||||
output.get("stderr", "").strip()
|
||||
or output.get("stdout", "").strip()
|
||||
)
|
||||
err_msg = next(
|
||||
(
|
||||
s.get(
|
||||
"error_message",
|
||||
runtime_error or "Step did not update status",
|
||||
)
|
||||
for s in index["steps"]
|
||||
if s["step"] == step_num
|
||||
),
|
||||
runtime_error or "Step did not update status",
|
||||
current = next(s for s in index["steps"] if s["step"] == step_num)
|
||||
reason = current.get("error_message") or (
|
||||
f"Codex exited with code {output['exitCode']}"
|
||||
if output["exitCode"] else "Step did not update status"
|
||||
)
|
||||
diagnostics = [reason]
|
||||
for stream in ("stderr", "stdout"):
|
||||
if output.get(stream, "").strip():
|
||||
diagnostics.append(f"{stream}:\n{output[stream].strip()}")
|
||||
err_msg = self._bounded_error("\n\n".join(diagnostics))
|
||||
|
||||
if attempt < self.MAX_RETRIES:
|
||||
for s in index["steps"]:
|
||||
@@ -421,7 +507,7 @@ class StepExecutor:
|
||||
|
||||
return False # unreachable
|
||||
|
||||
def _execute_all_steps(self, guardrails: str):
|
||||
def _execute_all_steps(self):
|
||||
while True:
|
||||
index = self._read_json(self._index_file)
|
||||
pending = next((s for s in index["steps"] if s["status"] == "pending"), None)
|
||||
@@ -436,7 +522,7 @@ class StepExecutor:
|
||||
self._write_json(self._index_file, index)
|
||||
break
|
||||
|
||||
self._execute_single_step(pending, guardrails)
|
||||
self._execute_single_step(pending)
|
||||
|
||||
def _finalize(self):
|
||||
index = self._read_json(self._index_file)
|
||||
@@ -467,12 +553,14 @@ class StepExecutor:
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Harness Step Executor")
|
||||
parser.add_argument("phase_dir", help="Phase directory name (e.g. 0-mvp)")
|
||||
parser.add_argument("--push", action="store_true", help="Push branch after completion")
|
||||
mode = parser.add_mutually_exclusive_group()
|
||||
mode.add_argument("--push", action="store_true", help="Push branch after completion")
|
||||
mode.add_argument("--dry-run", action="store_true", help="Preview first pending step context without execution")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
StepExecutor(args.phase_dir, auto_push=args.push).run()
|
||||
except CodexEnvironmentError as exc:
|
||||
StepExecutor(args.phase_dir, auto_push=args.push).run(dry_run=args.dry_run)
|
||||
except (CodexEnvironmentError, ContextError) as exc:
|
||||
print(f"ERROR: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
@@ -48,6 +49,7 @@ def _project_root(
|
||||
cwd=resolved_cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
shell=False,
|
||||
timeout=_remaining(deadline, clock, "repository discovery"),
|
||||
)
|
||||
@@ -61,6 +63,110 @@ def _project_root(
|
||||
return resolved_cwd
|
||||
|
||||
|
||||
def _changed_path_kind(root: Path, phase: Path, raw: str) -> str | None:
|
||||
if (not raw or raw.endswith("/") or "\\" in raw
|
||||
or PureWindowsPath(raw).drive or PureWindowsPath(raw).root
|
||||
or ".." in raw.split("/")):
|
||||
return None
|
||||
path = root / raw
|
||||
if path.exists() and not path.is_file():
|
||||
return None
|
||||
resolved = path.resolve()
|
||||
# Symlinks/junctions can make an apparent document modify a build input.
|
||||
if not resolved.is_relative_to(root) or resolved != path:
|
||||
return None
|
||||
relative = resolved.relative_to(root)
|
||||
if relative == Path("AGENTS.md"):
|
||||
return "doc"
|
||||
if relative.parts and relative.parts[0] == "docs" and relative.suffix.lower() == ".md":
|
||||
return "doc"
|
||||
if relative.parent == phase and (relative.name == "index.json"
|
||||
or re.fullmatch(r"step\d+-output\.json", relative.name)):
|
||||
return "metadata"
|
||||
return None
|
||||
|
||||
|
||||
def _was_regular_file(root: Path, raw: str, *, deadline: float, run, clock) -> bool:
|
||||
"""Missing paths may be deleted gitlinks rather than deleted Markdown files."""
|
||||
for args in (("ls-files", "--stage", "-z", "--", raw),
|
||||
("ls-tree", "-z", "HEAD", "--", raw)):
|
||||
result = run(
|
||||
["git", *args], cwd=root, capture_output=True, text=True,
|
||||
encoding="utf-8", errors="strict", shell=False,
|
||||
timeout=_remaining(deadline, clock, "deleted document type detection"),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
if not result.stdout:
|
||||
continue
|
||||
records = result.stdout.split("\0")
|
||||
if len(records) != 2 or records[1] or "\t" not in records[0]:
|
||||
return False
|
||||
info, name = records[0].split("\t", 1)
|
||||
fields = info.split()
|
||||
return name == raw and len(fields) == 3 and fields[0] in {"100644", "100755"}
|
||||
return False
|
||||
|
||||
|
||||
def _documentation_only_changes(
|
||||
root: Path,
|
||||
phase_dir: str,
|
||||
*,
|
||||
deadline: float,
|
||||
run=subprocess.run,
|
||||
clock=time.monotonic,
|
||||
) -> bool:
|
||||
"""Opt-in plus an unambiguous, document-only Git diff permits skipping MSVC."""
|
||||
if not phase_dir:
|
||||
return False
|
||||
parts = PurePosixPath(phase_dir).parts
|
||||
if (len(parts) != 2 or parts[0] != "phases" or parts[1] in {".", ".."}
|
||||
or "\\" in phase_dir or ":" in phase_dir):
|
||||
return False
|
||||
root = root.resolve()
|
||||
phase = Path(*parts)
|
||||
try:
|
||||
if (root / phase).resolve() != root / phase:
|
||||
return False
|
||||
result = run(
|
||||
["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="strict",
|
||||
shell=False,
|
||||
timeout=_remaining(deadline, clock, "documentation change detection"),
|
||||
)
|
||||
if result.returncode != 0 or not result.stdout or not result.stdout.endswith("\0"):
|
||||
return False
|
||||
records = iter(result.stdout[:-1].split("\0"))
|
||||
has_document = False
|
||||
for record in records:
|
||||
if len(record) < 4 or record[2] != " ":
|
||||
return False
|
||||
status = record[:2]
|
||||
if status != "??" and (any(c not in " MARCD" for c in status)
|
||||
or status in {" ", "AA", "DD"}):
|
||||
return False
|
||||
paths = [record[3:]]
|
||||
if "R" in status or "C" in status:
|
||||
paths.append(next(records)) # -z uses destination, then original path.
|
||||
for raw in paths:
|
||||
kind = _changed_path_kind(root, phase, raw)
|
||||
if kind is None:
|
||||
return False
|
||||
if not (root / raw).exists() and not _was_regular_file(
|
||||
root, raw, deadline=deadline, run=run, clock=clock,
|
||||
):
|
||||
return False
|
||||
has_document |= kind == "doc"
|
||||
return has_document
|
||||
except (OSError, UnicodeError, ValueError, StopIteration,
|
||||
subprocess.TimeoutExpired, ValidationFailure):
|
||||
return False
|
||||
|
||||
|
||||
def run_validations(
|
||||
root: Path,
|
||||
*,
|
||||
@@ -133,6 +239,11 @@ def main(*, run=subprocess.run, clock=time.monotonic) -> int:
|
||||
run=run,
|
||||
clock=clock,
|
||||
)
|
||||
if _documentation_only_changes(
|
||||
root, os.environ.get("HARNESS_DOCS_ONLY_PHASE", ""),
|
||||
deadline=deadline, run=run, clock=clock,
|
||||
):
|
||||
return 0
|
||||
ok, message = run_validations(root, deadline=deadline, clock=clock)
|
||||
except (OSError, ValidationFailure) as exc:
|
||||
_emit_stop_response(f"validation hook failed: {exc}")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
from .models import DiscoveryResult, HarnessConfig, ProjectKind, ProjectSelection
|
||||
from .tdd_policy import find_cpp_files
|
||||
from .tdd_policy import has_cpp_files
|
||||
|
||||
|
||||
class DiscoveryError(RuntimeError):
|
||||
@@ -68,8 +68,7 @@ def discover_project(root: Path, config: HarnessConfig) -> DiscoveryResult:
|
||||
ProjectSelection(ProjectKind.MSBUILD, projects[0])
|
||||
)
|
||||
|
||||
cpp_files = find_cpp_files(root)
|
||||
if cpp_files:
|
||||
if has_cpp_files(root):
|
||||
raise DiscoveryError(
|
||||
"C/C++ files exist but no CMakeLists.txt, preset, .sln, or .vcxproj "
|
||||
"was found; configure projectType and project path"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import os
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
CPP_SUFFIXES = frozenset({".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".hxx"})
|
||||
DEFAULT_EXCLUDES = (
|
||||
@@ -22,18 +24,70 @@ def is_excluded_path(path: Path, root: Path, extra: tuple[str, ...] = ()) -> boo
|
||||
return any(fnmatch(relative, pattern) for pattern in DEFAULT_EXCLUDES + extra)
|
||||
|
||||
|
||||
def _fully_excluded_directory(
|
||||
path: Path,
|
||||
root: Path,
|
||||
patterns: tuple[str, ...],
|
||||
) -> bool:
|
||||
try:
|
||||
relative = path.resolve().relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
return False
|
||||
for pattern in patterns:
|
||||
for suffix in ("/**", "\\**"):
|
||||
if pattern.endswith(suffix) and fnmatch(relative, pattern[:-3]):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _iter_files(
|
||||
root: Path,
|
||||
pruned_patterns: tuple[str, ...] = (),
|
||||
) -> Iterator[Path]:
|
||||
for current, directories, files in os.walk(root, followlinks=False):
|
||||
current_path = Path(current)
|
||||
if pruned_patterns:
|
||||
directories[:] = [
|
||||
name
|
||||
for name in directories
|
||||
if not _fully_excluded_directory(
|
||||
current_path / name,
|
||||
root,
|
||||
pruned_patterns,
|
||||
)
|
||||
]
|
||||
for name in files:
|
||||
path = current_path / name
|
||||
if path.is_file():
|
||||
yield path
|
||||
|
||||
|
||||
def _iter_cpp_files(
|
||||
root: Path,
|
||||
extra_excludes: tuple[str, ...] = (),
|
||||
) -> Iterator[Path]:
|
||||
root = root.resolve()
|
||||
excludes = DEFAULT_EXCLUDES + extra_excludes
|
||||
for path in _iter_files(root, excludes):
|
||||
if (
|
||||
path.suffix.lower() in CPP_SUFFIXES
|
||||
and not is_excluded_path(path, root, extra_excludes)
|
||||
):
|
||||
yield path
|
||||
|
||||
|
||||
def find_cpp_files(
|
||||
root: Path,
|
||||
extra_excludes: tuple[str, ...] = (),
|
||||
) -> tuple[Path, ...]:
|
||||
found = (
|
||||
path
|
||||
for path in root.resolve().rglob("*")
|
||||
if path.is_file()
|
||||
and path.suffix.lower() in CPP_SUFFIXES
|
||||
and not is_excluded_path(path, root, extra_excludes)
|
||||
)
|
||||
return tuple(sorted(found))
|
||||
return tuple(sorted(_iter_cpp_files(root, extra_excludes)))
|
||||
|
||||
|
||||
def has_cpp_files(
|
||||
root: Path,
|
||||
extra_excludes: tuple[str, ...] = (),
|
||||
) -> bool:
|
||||
return next(_iter_cpp_files(root, extra_excludes), None) is not None
|
||||
|
||||
|
||||
def is_test_file(path: Path, root: Path, config) -> bool:
|
||||
@@ -47,7 +101,60 @@ def is_test_file(path: Path, root: Path, config) -> bool:
|
||||
return stem.startswith("test_") or stem.endswith(("_test", "_tests", ".test"))
|
||||
|
||||
|
||||
def matching_test_exists(path: Path, config) -> bool:
|
||||
def _is_simple_filename_pattern(pattern: str) -> bool:
|
||||
pattern_path = Path(pattern)
|
||||
return (
|
||||
not pattern_path.is_absolute()
|
||||
and len(pattern_path.parts) == 1
|
||||
and pattern == pattern_path.name
|
||||
and "**" not in pattern
|
||||
and not pattern.endswith(("/", "\\"))
|
||||
)
|
||||
|
||||
|
||||
class _TestFileIndex:
|
||||
def __init__(self) -> None:
|
||||
self._roots: dict[Path, Path] = {}
|
||||
self._files: dict[Path, list[Path]] = {}
|
||||
self._iterators: dict[Path, Iterator[Path]] = {}
|
||||
self._exhausted: set[Path] = set()
|
||||
self._path_matches: dict[tuple[Path, str], bool] = {}
|
||||
|
||||
def _path_pattern_match(
|
||||
self,
|
||||
key: Path,
|
||||
root: Path,
|
||||
pattern: str,
|
||||
) -> bool:
|
||||
cache_key = (key, pattern)
|
||||
if cache_key not in self._path_matches:
|
||||
self._path_matches[cache_key] = any(
|
||||
candidate.is_file() for candidate in root.rglob(pattern)
|
||||
)
|
||||
return self._path_matches[cache_key]
|
||||
|
||||
def matching_file_exists(self, root: Path, pattern: str) -> bool:
|
||||
key = root.resolve()
|
||||
traversal_root = self._roots.setdefault(key, root.absolute())
|
||||
if not _is_simple_filename_pattern(pattern):
|
||||
return self._path_pattern_match(key, traversal_root, pattern)
|
||||
|
||||
files = self._files.setdefault(key, [])
|
||||
if any(fnmatch(path.name, pattern) for path in files):
|
||||
return True
|
||||
if key in self._exhausted:
|
||||
return False
|
||||
|
||||
iterator = self._iterators.setdefault(key, _iter_files(traversal_root))
|
||||
for path in iterator:
|
||||
files.append(path)
|
||||
if fnmatch(path.name, pattern):
|
||||
return True
|
||||
self._exhausted.add(key)
|
||||
return False
|
||||
|
||||
|
||||
def _matching_test_exists(path: Path, config, index: _TestFileIndex) -> bool:
|
||||
names = tuple(pattern.format(stem=path.stem) for pattern in config.test_patterns)
|
||||
roots = (
|
||||
*config.test_roots,
|
||||
@@ -58,13 +165,18 @@ def matching_test_exists(path: Path, config) -> bool:
|
||||
if not root.is_dir():
|
||||
continue
|
||||
for name in names:
|
||||
if any(candidate.is_file() for candidate in root.rglob(name)):
|
||||
if index.matching_file_exists(root, name):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def matching_test_exists(path: Path, config) -> bool:
|
||||
return _matching_test_exists(path, config, _TestFileIndex())
|
||||
|
||||
|
||||
def evaluate_paths(paths, root: Path, config) -> str | None:
|
||||
root = root.resolve()
|
||||
test_files = _TestFileIndex()
|
||||
for raw in paths:
|
||||
path = raw.resolve()
|
||||
try:
|
||||
@@ -79,7 +191,7 @@ def evaluate_paths(paths, root: Path, config) -> str | None:
|
||||
or is_test_file(path, root, config)
|
||||
):
|
||||
continue
|
||||
if not matching_test_exists(path, config):
|
||||
if not _matching_test_exists(path, config, test_files):
|
||||
expected = config.test_patterns[0].format(stem=path.stem)
|
||||
return (
|
||||
f"TDD GUARD: '{path.name}' requires an existing test such as "
|
||||
|
||||
Reference in New Issue
Block a user