modify harness framework

This commit is contained in:
김경종
2026-09-08 10:30:07 +09:00
parent 1239d5b9f6
commit 1a0f3c9aaf
68 changed files with 6729 additions and 957 deletions
@@ -0,0 +1,172 @@
---
name: harness
description: Use when planning agentic implementation phases, creating phases/index.json and self-contained step files, or running the Harness step executor.
---
# Harness Workflow
이 프로젝트는 Harness 프레임워크를 사용한다. 아래 워크플로에 따라 작업한다.
## A. 탐색
`AGENTS.md`와 `docs/` 하위 문서(PRD, ARCHITECTURE, ADR 등)를 읽고 프로젝트의 기획,
아키텍처, 설계 의도를 파악한다. 병렬 탐색이 실제로 유용하고 현재 세션에서 허용될
때만 Codex subagent를 선택적으로 사용한다.
## B. 논의
구현을 위해 구체화하거나 기술적으로 결정해야 할 사항이 있으면 사용자에게 한 번에
하나씩 제시하고 논의한다.
## C. Step 설계
사용자가 구현 계획 작성을 지시하면 여러 step으로 나뉜 초안을 작성해 피드백을
요청한다.
설계 원칙:
1. **Scope 최소화** — 하나의 step에서 하나의 레이어 또는 모듈만 다룬다. 여러
모듈을 동시에 수정해야 하면 step을 쪼갠다.
2. **자기완결성** — 각 step 파일은 독립된 Codex 실행에서 사용된다. 외부 대화
참조를 금지하고 필요한 정보를 모두 파일 안에 적는다.
3. **사전 준비 강제** — 관련 문서와 이전 step에서 생성하거나 수정한 파일 경로를
명시한다.
4. **시그니처 수준 지시** — 함수와 클래스의 인터페이스를 제시하고 내부 구현은
Codex 재량에 맡긴다. 멱등성, 보안, 데이터 무결성 같은 핵심 규칙은 명시한다.
5. **AC는 실행 가능한 command** — 추상적 조건 대신 실제 빌드와 테스트 command를
포함한다.
6. **주의사항은 구체적으로** — "X를 하지 마라. 이유: Y" 형식으로 적는다.
7. **네이밍** — step name은 핵심 작업을 표현하는 kebab-case slug로 정한다.
## D. 파일 생성
사용자가 초안을 승인한 후에만 다음 파일을 생성한다.
### D-1. `phases/index.json`
여러 task를 관리하는 top-level 인덱스다. 이미 존재하면 `phases` 배열에 새 항목을
추가한다.
```json
{
"phases": [
{
"dir": "0-mvp",
"status": "pending"
}
]
}
```
- `dir`: task 디렉터리명
- `status`: `pending` | `completed` | `error` | `blocked`
- 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" }
]
}
```
필드 규칙:
- `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`
````markdown
# Step {N}: {이름}
## 읽어야 할 파일
먼저 아래 파일을 읽고 프로젝트의 아키텍처와 설계 의도를 파악하라:
- `/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
```
## 검증 절차
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. 실행
```bash
python scripts/execute.py {task-name}
python scripts/execute.py {task-name} --push
```
환경에서 Python 3 실행 명령이 `python3`이면 그 명령을 대신 사용한다.
executor가 처리하는 작업:
- `feat-{task-name}` 브랜치 생성 또는 checkout
- `AGENTS.md`와 `docs/*.md` guardrail 주입
- 완료 step의 summary를 다음 prompt에 누적
- 실패 시 최대 3회 재시도하며 이전 오류를 prompt에 전달
- 코드 변경과 metadata를 분리해 commit
- `started_at`, `completed_at`, `failed_at`, `blocked_at` 기록
에러 복구:
- `error`: 해당 status를 `pending`으로 바꾸고 `error_message`를 삭제한 뒤 재실행
- `blocked`: 원인을 해결하고 status를 `pending`으로 바꾸고 `blocked_reason`을 삭제한
뒤 재실행
@@ -0,0 +1,41 @@
---
name: review
description: Use when reviewing repository changes against AGENTS.md, architecture decisions, tests, and build requirements.
---
# Repository Review
먼저 다음 문서를 읽는다.
- `/AGENTS.md`
- `/docs/ARCHITECTURE.md`
- `/docs/ADR.md`
사용자가 범위를 지정하지 않으면 현재 작업 트리의 변경을 리뷰한다. 관련 diff를
확인하고 가능한 빌드와 테스트 command를 실제로 실행한다.
## 체크리스트
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 경계를 지키는가?
## 출력 형식
실제 결함을 심각도순으로 먼저 제시한다. 각 finding에 파일과 줄 번호, 영향,
재현 또는 근거, 구체적인 수정 방안을 포함한다.
그 뒤 다음 표를 제공한다.
| 항목 | 결과 | 비고 |
|------|------|------|
| 아키텍처 준수 | ✅/❌/미검증 | 상세 |
| 기술 스택 준수 | ✅/❌/미검증 | 상세 |
| 테스트 존재 | ✅/❌/미검증 | 상세 |
| CRITICAL 규칙 | ✅/❌/미검증 | 상세 |
| 빌드 가능 | ✅/❌/미검증 | 상세 |
실행할 수 없는 검사는 성공으로 추정하지 말고 `미검증`과 이유를 적는다. finding이
없으면 발견된 문제가 없다고 명시하고 남아 있는 검증 공백을 설명한다.
@@ -0,0 +1,32 @@
{
"description": "Harness TDD, command safety, and MSVC C/C++ validation hooks.",
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|shell_command|PowerShell|apply_patch|Edit|MultiEdit|Write",
"hooks": [
{
"type": "command",
"command": "python3 -X utf8 \"$(git rev-parse --show-toplevel)/scripts/hooks/pre_tool_use.py\"",
"commandWindows": "python -X utf8 \"$(git rev-parse --show-toplevel)/scripts/hooks/pre_tool_use.py\"",
"timeout": 30,
"statusMessage": "Checking Harness policies"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 -X utf8 \"$(git rev-parse --show-toplevel)/scripts/hooks/stop_validation.py\"",
"commandWindows": "python -X utf8 \"$(git rev-parse --show-toplevel)/scripts/hooks/stop_validation.py\"",
"timeout": 1800,
"statusMessage": "Running MSVC build and tests"
}
]
}
]
}
}
@@ -0,0 +1,4 @@
__pycache__/
.pytest_cache/
*.py[cod]
.harness/build/
@@ -0,0 +1,31 @@
{
"version": 1,
"projectType": "auto",
"cmake": {
"sourceDir": ".",
"binaryDir": "out/build/windows-debug",
"configurePreset": "windows-debug",
"buildPreset": "windows-debug",
"testPreset": "windows-debug"
},
"msbuild": {
"solution": "MyProject.sln",
"configuration": "Debug",
"platform": "x64",
"testCommand": [
"build/tests/Debug/MyProjectTests.exe"
]
},
"tdd": {
"testRoots": [
"tests"
],
"testPatterns": [
"{stem}_test.cpp",
"test_{stem}.cpp"
],
"exclude": [
"legacy/generated/**"
]
}
}
@@ -0,0 +1,40 @@
# 프로젝트: {프로젝트명}
## 기술 스택
- MSVC toolset: {예: v143}
- C++ 표준: {예: C++20}
- 빌드 시스템: {CMake 또는 Visual Studio solution}
- 테스트 프레임워크: {예: GoogleTest, Catch2, Microsoft C++ Unit Test}
## 아키텍처 규칙
- CRITICAL: {절대 지켜야 할 모듈 또는 계층 경계}
- CRITICAL: public header와 implementation 의존성 방향을 역전하지 말 것
- 제품 코드는 `src/`, public header는 `include/`, 테스트는 `tests/`에 둔다.
## 개발 프로세스
- CRITICAL: 새 기능과 버그 수정은 실패하는 테스트를 먼저 작성한 뒤 구현할 것.
- CRITICAL: 빌드 경고를 새로 추가하지 말 것.
- 커밋 메시지는 conventional commits 형식을 따른다.
## 명령어
### CMake/CTest 프로젝트
```powershell
cmake -S . -B .harness/build -A x64
cmake --build .harness/build --config Debug
ctest --test-dir .harness/build -C Debug --output-on-failure
```
### 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`과 프로젝트 문서를
우선한다.
@@ -0,0 +1,21 @@
# Architecture Decision Records
## 철학
{프로젝트의 핵심 가치관 (예: MVP 속도 최우선. 외부 의존성 최소화. 작동하는 최소 구현을 선택.)}
---
### ADR-001: {결정 사항 (예: Next.js App Router 선택)}
**결정**: {뭘 선택했는지}
**이유**: {왜 선택했는지}
**트레이드오프**: {뭘 포기했는지}
### ADR-002: {결정 사항}
**결정**: {뭘 선택했는지}
**이유**: {왜 선택했는지}
**트레이드오프**: {뭘 포기했는지}
### ADR-003: {결정 사항}
**결정**: {뭘 선택했는지}
**이유**: {왜 선택했는지}
**트레이드오프**: {뭘 포기했는지}
@@ -0,0 +1,24 @@
# 아키텍처
## 디렉토리 구조
```
src/
├── app/ # 페이지 + API 라우트
├── components/ # UI 컴포넌트
├── types/ # TypeScript 타입 정의
├── lib/ # 유틸리티 + 헬퍼
└── services/ # 외부 API 래퍼
```
## 패턴
{사용하는 디자인 패턴 (예: Server Components 기본, 인터랙션이 필요한 곳만 Client Component)}
## 데이터 흐름
```
{데이터가 어떻게 흐르는지 (예:
사용자 입력 → Client Component → API Route → 외부 API → 응답 → UI 업데이트
)}
```
## 상태 관리
{상태 관리 방식 (예: 서버 상태는 Server Components, 클라이언트 상태는 useState/useReducer)}
@@ -0,0 +1,120 @@
# Harness 운영 가이드
요구사항 접수부터 Step 실행, TDD 정책, MSVC 빌드·테스트, 재시도와 커밋까지의
전체 흐름은 [Harness Framework 동작 과정](HARNESS_WORKFLOW.md)을 참고한다.
## Requirements
Windows, Python 3.10 이상, Codex CLI가 필요하다. CMake 프로젝트에는 Visual Studio의
Desktop development with C++ 워크로드와 MSBuild, CMake/CTest를 설치한다.
## 프로젝트 자동 감지
프로젝트 형식은 다음 순서로 결정한다: `.harness/config.json`의 명시적 type, 루트의
CMake metadata, 하나의 `.sln`, 하나의 `.vcxproj` 순서다. C/C++가 아닌 저장소는
건너뛰며, C/C++ 파일은 있지만 CMake/solution metadata가 없는 orphan-C++ 저장소는
오류로 처리한다.
설정을 시작하려면 다음을 실행한다.
```powershell
Copy-Item .harness/config.example.json .harness/config.json
python scripts/execute.py <phase-name>
python scripts/execute.py <phase-name> --push
```
## Harness Python 검증
이 저장소의 테스트와 최종 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/`처럼 격리된 경로에 둔다.
```json
{
"version": 1,
"projectType": "cmake",
"cmake": {
"sourceDir": ".",
"binaryDir": "out/build/windows-debug",
"configurePreset": "windows-debug",
"buildPreset": "windows-debug",
"testPreset": "windows-debug"
}
}
```
```powershell
cmake --preset windows-debug
cmake --build --preset windows-debug
ctest --preset windows-debug --output-on-failure
```
Preset을 쓰지 않는 경우에는 같은 격리된 build directory를 명시한다.
```powershell
cmake -S . -B .harness/build -A x64
cmake --build .harness/build --config Debug
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"]
}
}
```
```powershell
MSBuild.exe MyProject.sln /m /p:Configuration=Debug /p:Platform=x64
.\build\tests\Debug\MyProjectTests.exe
```
## TDD 확장
`tdd.testRoots`와 `tdd.testPatterns`로 테스트 위치와 이름을 확장한다. 패턴마다
`{stem}`이 필요하다. `main`, 테스트, 외부 의존성, 생성 파일, build directory 같은
기본 제외 항목은 Harness가 관리하며, `tdd.exclude`의 사용자 제외 항목은 이를
대체하지 않고 추가한다.
```json
{
"version": 1,
"tdd": {
"testRoots": ["tests", "integration-tests"],
"testPatterns": ["{stem}_test.cpp", "test_{stem}.cpp"],
"exclude": ["legacy/generated/**"]
}
}
```
## 실패 복구
- 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는
별도의 로그 파일을 만들지 않는다.
@@ -0,0 +1,602 @@
# Harness Framework 동작 과정
이 문서는 자연어 요구사항을 받은 뒤 Harness Framework가 계획을 만들고, 독립된
Codex 세션에서 Step을 실행하고, MSVC로 C++ 프로젝트를 검증하는 전체 과정을
설명한다. 설치 및 설정 예시는 [Harness 운영 가이드](HARNESS.md)를 참고한다.
## 1. 핵심 구조
Harness Framework는 다음 세 계층으로 구성된다.
1. **계획 계층**: 요구사항을 분석하고 사용자가 승인할 실행 가능한 Step으로 변환한다.
2. **실행 계층**: Step Executor가 Step마다 독립 Codex 세션을 실행하고 상태와 Git
커밋을 관리한다.
3. **검증 계층**: PreToolUse 훅이 편집 전 정책을 검사하고, Stop 훅이 종료 전 MSVC
빌드와 테스트를 실행한다.
전체 흐름은 다음과 같다.
```text
사용자 요구사항
↓
프로젝트 탐색 및 요구사항 논의
↓
Step 초안 작성
↓
사용자 승인
↓
phases/index.json, task index, stepN.md 생성
↓
Step Executor 시작
↓
각 Step을 독립 Codex 세션에서 실행
├─ 도구 호출 전: PreToolUse 정책 검사
└─ 응답 종료 전: Stop MSVC 빌드·테스트
↓
성공: 커밋 후 다음 Step
실패: 수정 또는 최대 3회 재시도
차단: 사용자 개입을 기다리며 중단
```
자연어 요구사항만으로 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 초안을 사용자가 승인한 뒤에만 다음 파일을 생성한다.
```text
phases/
├── index.json
└── add-division/
├── index.json
├── step0.md
├── step1.md
└── ...
```
### 4.1 Top-level index
`phases/index.json`은 여러 task의 상태를 관리한다.
```json
{
"phases": [
{
"dir": "add-division",
"status": "pending"
}
]
}
```
### 4.2 Task index
`phases/add-division/index.json`은 task 내부 Step의 상태를 관리한다.
```json
{
"project": "Calculator",
"phase": "add-division",
"steps": [
{
"step": 0,
"name": "division-api",
"status": "pending"
}
]
}
```
상태별 기록은 다음과 같이 나뉜다.
| 상태 | 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 시작
계획 파일을 생성한 뒤 다음 명령으로 실행한다.
```powershell
python scripts/execute.py add-division
```
완료된 브랜치를 원격 저장소에 자동 push하려면 `--push`를 추가한다.
```powershell
python scripts/execute.py add-division --push
```
[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을 다음 형태의 독립 프로세스로 실행한다.
```text
codex exec
--json
--sandbox workspace-write
--dangerously-bypass-hook-trust
--cd <repository-root>
-
```
Codex에 전달하는 프롬프트는 다음 내용의 조합이다.
```text
AGENTS.md와 docs 문서
+ 이전에 완료된 Step의 summary
+ 이전 시도의 오류(재시도인 경우)
+ Executor 공통 작업 규칙
+ 현재 stepN.md
```
이전 Step의 전체 대화나 Codex 세션은 전달하지 않는다. task index에 기록한
`summary`만 다음 Step에 누적한다.
Codex 실행 결과의 exit code, stdout, stderr는 다음 파일에 저장한다.
```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++ 프로젝트 오류 |
### 8.4 MSVC 도구 탐색
[도구 탐색기](../scripts/msvc_harness/toolchain.py)는 `vswhere.exe`로 다음을
확인한다.
- Visual Studio 설치 경로
- Desktop development with C++ workload
- `MSBuild.exe`
CMake 프로젝트에서는 다음 우선순위로 CMake와 CTest를 선택한다.
1. PATH에서 발견한 독립 `cmake.exe`와 `ctest.exe`
2. Visual Studio에 번들된 CMake와 CTest
따라서 새로 설치한 CMake의 `bin` 디렉터리가 PATH에 반영되어 있으면 독립 CMake를
우선 사용한다.
## 9. 빌드 시스템별 검증 계획
### 9.1 CMake preset 미사용
[CMake adapter](../scripts/msvc_harness/adapters/cmake.py)는 다음 검증 계획을 만든다.
```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 compiler metadata의 `CMAKE_CXX_COMPILER_ID`가 `MSVC`인가
- CTest JSON에 한 개 이상의 테스트가 있는가
따라서 빌드가 성공해도 MinGW 등 다른 컴파일러를 사용했거나 CTest가 테스트를 한
개도 발견하지 못하면 실패한다.
### 9.2 CMake preset 사용
`.harness/config.json`에 preset을 완전히 지정하면 다음 형태로 실행한다.
```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가 남아 있으면 실제 작업 지시와 충돌할 수 있다.
@@ -0,0 +1,21 @@
# PRD: {프로젝트명}
## 목표
{이 프로젝트가 해결하려는 문제를 한 줄로 요약}
## 사용자
{누가 이 제품을 쓰는지}
## 핵심 기능
1. {기능 1}
2. {기능 2}
3. {기능 3}
## MVP 제외 사항
- {안 만들 것 1}
- {안 만들 것 2}
- {안 만들 것 3}
## 디자인
- {디자인 방향 (예: 다크모드 고정, 미니멀)}
- {색상 (예: 무채색 + 포인트 1가지)}
@@ -0,0 +1,481 @@
#!/usr/bin/env python3
"""
Harness Step Executor — phase 내 step을 순차 실행하고 자가 교정한다.
Usage:
python scripts/execute.py <phase-dir> [--push]
"""
import argparse
import contextlib
import json
import os
import subprocess
import sys
import threading
import time
import types
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Optional
ROOT = Path(__file__).resolve().parent.parent
class CodexEnvironmentError(RuntimeError):
"""재시도로 해결할 수 없는 Codex CLI 환경 오류."""
@contextlib.contextmanager
def progress_indicator(label: str):
"""터미널 진행 표시기. with 문으로 사용하며 .elapsed 로 경과 시간을 읽는다."""
frames = "◐◓◑◒"
stop = threading.Event()
t0 = time.monotonic()
def _animate():
idx = 0
while not stop.wait(0.12):
sec = int(time.monotonic() - t0)
sys.stderr.write(f"\r{frames[idx % len(frames)]} {label} [{sec}s]")
sys.stderr.flush()
idx += 1
sys.stderr.write("\r" + " " * (len(label) + 20) + "\r")
sys.stderr.flush()
th = threading.Thread(target=_animate, daemon=True)
th.start()
info = types.SimpleNamespace(elapsed=0.0)
try:
yield info
finally:
stop.set()
th.join()
info.elapsed = time.monotonic() - t0
class StepExecutor:
"""Phase 디렉토리 안의 step들을 순차 실행하는 하네스."""
MAX_RETRIES = 3
FEAT_MSG = "feat({phase}): step {num} — {name}"
CHORE_MSG = "chore({phase}): step {num} output"
TZ = timezone(timedelta(hours=9))
def __init__(self, phase_dir_name: str, *, auto_push: bool = False):
self._root = str(ROOT)
self._phases_dir = ROOT / "phases"
self._phase_dir = self._phases_dir / phase_dir_name
self._phase_dir_name = phase_dir_name
self._top_index_file = self._phases_dir / "index.json"
self._auto_push = auto_push
if not self._phase_dir.is_dir():
print(f"ERROR: {self._phase_dir} not found")
sys.exit(1)
self._index_file = self._phase_dir / "index.json"
if not self._index_file.exists():
print(f"ERROR: {self._index_file} not found")
sys.exit(1)
idx = self._read_json(self._index_file)
self._project = idx.get("project", "project")
self._phase_name = idx.get("phase", phase_dir_name)
self._total = len(idx["steps"])
def run(self):
self._print_header()
self._check_blockers()
self._checkout_branch()
guardrails = self._load_guardrails()
self._ensure_created_at()
self._execute_all_steps(guardrails)
self._finalize()
# --- timestamps ---
def _stamp(self) -> str:
return datetime.now(self.TZ).strftime("%Y-%m-%dT%H:%M:%S%z")
# --- JSON I/O ---
@staticmethod
def _read_json(p: Path) -> dict:
return json.loads(p.read_text(encoding="utf-8"))
@staticmethod
def _write_json(p: Path, data: dict):
p.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
# --- git ---
def _run_git(self, *args) -> subprocess.CompletedProcess:
cmd = ["git"] + list(args)
return subprocess.run(cmd, cwd=self._root, capture_output=True, text=True)
def _checkout_branch(self):
branch = f"feat-{self._phase_name}"
r = self._run_git("rev-parse", "--abbrev-ref", "HEAD")
if r.returncode != 0:
print(f" ERROR: git을 사용할 수 없거나 git repo가 아닙니다.")
print(f" {r.stderr.strip()}")
sys.exit(1)
if r.stdout.strip() == branch:
return
r = self._run_git("rev-parse", "--verify", branch)
r = self._run_git("checkout", branch) if r.returncode == 0 else self._run_git("checkout", "-b", branch)
if r.returncode != 0:
print(f" ERROR: 브랜치 '{branch}' checkout 실패.")
print(f" {r.stderr.strip()}")
print(f" Hint: 변경사항을 stash하거나 commit한 후 다시 시도하세요.")
sys.exit(1)
print(f" Branch: {branch}")
def _commit_step(self, step_num: int, step_name: str):
output_rel = f"phases/{self._phase_dir_name}/step{step_num}-output.json"
index_rel = f"phases/{self._phase_dir_name}/index.json"
self._run_git("add", "-A")
self._run_git("reset", "HEAD", "--", output_rel)
self._run_git("reset", "HEAD", "--", index_rel)
if self._run_git("diff", "--cached", "--quiet").returncode != 0:
msg = self.FEAT_MSG.format(phase=self._phase_name, num=step_num, name=step_name)
r = self._run_git("commit", "-m", msg)
if r.returncode == 0:
print(f" Commit: {msg}")
else:
print(f" WARN: 코드 커밋 실패: {r.stderr.strip()}")
self._run_git("add", "-A")
if self._run_git("diff", "--cached", "--quiet").returncode != 0:
msg = self.CHORE_MSG.format(phase=self._phase_name, num=step_num)
r = self._run_git("commit", "-m", msg)
if r.returncode != 0:
print(f" WARN: housekeeping 커밋 실패: {r.stderr.strip()}")
# --- top-level index ---
def _update_top_index(self, status: str):
if not self._top_index_file.exists():
return
top = self._read_json(self._top_index_file)
ts = self._stamp()
for phase in top.get("phases", []):
if phase.get("dir") == self._phase_dir_name:
phase["status"] = status
ts_key = {"completed": "completed_at", "error": "failed_at", "blocked": "blocked_at"}.get(status)
if ts_key:
phase[ts_key] = ts
break
self._write_json(self._top_index_file, top)
# --- 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 ""
@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 _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"
)
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"3. 기존 테스트를 깨뜨리지 마라.\n"
f"4. AC(Acceptance Criteria) 검증을 직접 실행하라.\n"
f"5. /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" - 사용자 개입이 필요한 경우 (API 키, 인증, 수동 설정 등) → \"blocked\" + \"blocked_reason\" 기록 후 즉시 중단\n"
f"6. 변경사항을 직접 커밋하지 마라. Git 커밋과 timestamp는 실행기가 처리한다.\n\n"
f"---\n\n"
)
# --- Codex 호출 ---
def _invoke_codex(self, step: dict, preamble: 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")
command = [
"codex",
"exec",
"--json",
"--sandbox",
"workspace-write",
"--dangerously-bypass-hook-trust",
"--cd",
self._root,
"-",
]
try:
result = subprocess.run(
command,
cwd=self._root,
input=prompt,
capture_output=True,
text=True,
timeout=1800,
)
except FileNotFoundError as exc:
raise CodexEnvironmentError(
"Codex CLI를 찾을 수 없습니다. codex를 설치하고 PATH를 확인하세요."
) from exc
if result.returncode != 0:
print(f"\n WARN: Codex가 비정상 종료됨 (code {result.returncode})")
if result.stderr:
print(f" stderr: {result.stderr[:500]}")
output = {
"step": step_num, "name": step_name,
"exitCode": result.returncode,
"stdout": result.stdout, "stderr": result.stderr,
}
out_path = self._phase_dir / f"step{step_num}-output.json"
self._write_json(out_path, output)
return output
@staticmethod
def _codex_environment_failure(output: dict) -> Optional[str]:
if output.get("exitCode", 0) == 0:
return None
diagnostic = (
f"{output.get('stderr', '')}\n{output.get('stdout', '')}"
).lower()
markers = {
"not logged in": "Codex 인증이 필요합니다.",
"authentication": "Codex 인증에 실패했습니다.",
"unexpected argument": "현재 Codex CLI가 필요한 옵션을 지원하지 않습니다.",
"unrecognized option": "현재 Codex CLI가 필요한 옵션을 지원하지 않습니다.",
}
for marker, message in markers.items():
if marker in diagnostic:
return message
return None
# --- 헤더 & 검증 ---
def _print_header(self):
print(f"\n{'='*60}")
print(f" Harness Step Executor")
print(f" Phase: {self._phase_name} | Steps: {self._total}")
if self._auto_push:
print(f" Auto-push: enabled")
print(f"{'='*60}")
def _check_blockers(self):
index = self._read_json(self._index_file)
for s in reversed(index["steps"]):
if s["status"] == "error":
print(f"\n ✗ Step {s['step']} ({s['name']}) failed.")
print(f" Error: {s.get('error_message', 'unknown')}")
print(f" Fix and reset status to 'pending' to retry.")
sys.exit(1)
if s["status"] == "blocked":
print(f"\n ⏸ Step {s['step']} ({s['name']}) blocked.")
print(f" Reason: {s.get('blocked_reason', 'unknown')}")
print(f" Resolve and reset status to 'pending' to retry.")
sys.exit(2)
if s["status"] != "pending":
break
def _ensure_created_at(self):
index = self._read_json(self._index_file)
if "created_at" not in index:
index["created_at"] = self._stamp()
self._write_json(self._index_file, index)
# --- 실행 루프 ---
def _execute_single_step(self, step: dict, guardrails: str) -> 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")
prev_error = None
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)
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)
elapsed = int(pi.elapsed)
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":
for s in index["steps"]:
if s["step"] == step_num:
s["completed_at"] = ts
self._write_json(self._index_file, index)
self._commit_step(step_num, step_name)
print(f" ✓ Step {step_num}: {step_name} [{elapsed}s]")
return True
if status == "blocked":
for s in index["steps"]:
if s["step"] == step_num:
s["blocked_at"] = ts
self._write_json(self._index_file, index)
reason = next((s.get("blocked_reason", "") for s in index["steps"] if s["step"] == step_num), "")
print(f" ⏸ Step {step_num}: {step_name} blocked [{elapsed}s]")
print(f" Reason: {reason}")
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",
)
if attempt < self.MAX_RETRIES:
for s in index["steps"]:
if s["step"] == step_num:
s["status"] = "pending"
s.pop("error_message", None)
self._write_json(self._index_file, index)
prev_error = err_msg
print(f" ↻ Step {step_num}: retry {attempt}/{self.MAX_RETRIES} — {err_msg}")
else:
for s in index["steps"]:
if s["step"] == step_num:
s["status"] = "error"
s["error_message"] = f"[{self.MAX_RETRIES}회 시도 후 실패] {err_msg}"
s["failed_at"] = ts
self._write_json(self._index_file, index)
self._commit_step(step_num, step_name)
print(f" ✗ Step {step_num}: {step_name} failed after {self.MAX_RETRIES} attempts [{elapsed}s]")
print(f" Error: {err_msg}")
self._update_top_index("error")
sys.exit(1)
return False # unreachable
def _execute_all_steps(self, guardrails: str):
while True:
index = self._read_json(self._index_file)
pending = next((s for s in index["steps"] if s["status"] == "pending"), None)
if pending is None:
print("\n All steps completed!")
return
step_num = pending["step"]
for s in index["steps"]:
if s["step"] == step_num and "started_at" not in s:
s["started_at"] = self._stamp()
self._write_json(self._index_file, index)
break
self._execute_single_step(pending, guardrails)
def _finalize(self):
index = self._read_json(self._index_file)
index["completed_at"] = self._stamp()
self._write_json(self._index_file, index)
self._update_top_index("completed")
self._run_git("add", "-A")
if self._run_git("diff", "--cached", "--quiet").returncode != 0:
msg = f"chore({self._phase_name}): mark phase completed"
r = self._run_git("commit", "-m", msg)
if r.returncode == 0:
print(f" ✓ {msg}")
if self._auto_push:
branch = f"feat-{self._phase_name}"
r = self._run_git("push", "-u", "origin", branch)
if r.returncode != 0:
print(f"\n ERROR: git push 실패: {r.stderr.strip()}")
sys.exit(1)
print(f" ✓ Pushed to origin/{branch}")
print(f"\n{'='*60}")
print(f" Phase '{self._phase_name}' completed!")
print(f"{'='*60}")
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")
args = parser.parse_args()
try:
StepExecutor(args.phase_dir, auto_push=args.push).run()
except CodexEnvironmentError as exc:
print(f"ERROR: {exc}")
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Codex PreToolUse 정책: 위험 명령과 테스트 없는 구현 파일 수정을 차단한다."""
import json
import re
import sys
from pathlib import Path
from typing import Any
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
from msvc_harness.config import ConfigError, load_config
from msvc_harness.tdd_policy import CPP_SUFFIXES, evaluate_paths
DANGEROUS_PATTERNS = (
re.compile(r"\bgit\s+reset\s+--hard\b", re.IGNORECASE),
re.compile(r"\bgit\s+push\b[^\n]*--force(?:-with-lease)?\b", re.IGNORECASE),
re.compile(r"\brm\s+-rf\b", re.IGNORECASE),
re.compile(
r"\bRemove-Item\b(?=[^\n]*-Recurse\b)(?=[^\n]*-Force\b)",
re.IGNORECASE,
),
re.compile(
r"\b(?:rmdir|rd)\b(?=[^\n]*/s\b)(?=[^\n]*/q\b)",
re.IGNORECASE,
),
re.compile(r"\bDROP\s+TABLE\b", re.IGNORECASE),
)
PATCH_PATH = re.compile(
r"^\*\*\* (?:(?:Add|Update|Delete) File:|Move to:) (?P<path>.+?)\s*$",
re.MULTILINE,
)
def _tool_input(payload: dict[str, Any]) -> dict[str, Any]:
value = payload.get("tool_input", {})
return value if isinstance(value, dict) else {}
def _candidate_paths(payload: dict[str, Any], root: Path) -> list[Path]:
tool_input = _tool_input(payload)
raw_paths: list[str] = []
for key in ("path", "file_path"):
value = tool_input.get(key)
if isinstance(value, str) and value.strip():
raw_paths.append(value.strip())
edits = tool_input.get("edits")
if isinstance(edits, list):
for edit in edits:
if not isinstance(edit, dict):
continue
value = edit.get("path")
if isinstance(value, str) and value.strip():
raw_paths.append(value.strip())
for key in ("patch", "input"):
value = tool_input.get(key)
if isinstance(value, str):
raw_paths.extend(match.group("path") for match in PATCH_PATH.finditer(value))
paths: list[Path] = []
for raw_path in raw_paths:
path = Path(raw_path)
paths.append(path.resolve() if path.is_absolute() else (root / path).resolve())
return list(dict.fromkeys(paths))
def evaluate(payload: dict[str, Any], root: Path) -> str | None:
"""Return a blocking reason, or None when the tool call is allowed."""
tool_name = str(payload.get("tool_name", ""))
tool_input = _tool_input(payload)
if tool_name in {"Bash", "shell_command", "PowerShell"}:
command = tool_input.get("command", "")
if isinstance(command, str) and any(
pattern.search(command) for pattern in DANGEROUS_PATTERNS
):
return "위험한 명령어가 감지되어 실행을 차단했습니다."
if tool_name not in {"apply_patch", "Edit", "MultiEdit", "Write"}:
return None
paths = _candidate_paths(payload, root)
try:
config = load_config(root)
except ConfigError as exc:
if any(path.suffix.lower() in CPP_SUFFIXES for path in paths):
return f"TDD GUARD: .harness/config.json must be repaired: {exc}"
return None
return evaluate_paths(paths, root, config.tdd)
def main() -> int:
try:
payload = json.load(sys.stdin)
if not isinstance(payload, dict):
raise TypeError("hook input must be a JSON object")
cwd = payload.get("cwd")
if cwd is not None and not isinstance(cwd, str):
raise TypeError("hook cwd must be a string")
except (json.JSONDecodeError, TypeError) as exc:
print(f"TDD GUARD: hook 입력을 해석하지 못해 검사를 건너뜁니다: {exc}", file=sys.stderr)
return 0
root = Path(payload.get("cwd") or Path.cwd()).resolve()
reason = evaluate(payload, root)
if reason:
print(reason, file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Codex Stop hook: validate discovered C/C++ projects with MSVC."""
import json
import os
import subprocess
import sys
import time
from pathlib import Path
SCRIPTS = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SCRIPTS))
from msvc_harness.adapters.base import AdapterError
from msvc_harness.adapters.cmake import CMakeAdapter
from msvc_harness.adapters.msbuild import MSBuildAdapter
from msvc_harness.config import ConfigError, load_config
from msvc_harness.discovery import DiscoveryError, discover_project
from msvc_harness.models import ProjectKind
from msvc_harness.process import ValidationFailure, execute_plan
from msvc_harness.toolchain import ToolchainError, discover_toolchain
REENTRY_ENV = "CODEX_STOP_VALIDATION_ACTIVE"
TOTAL_TIMEOUT_SECONDS = 1800
def _remaining(deadline, clock, stage):
remaining = deadline - clock()
if remaining <= 0:
raise ValidationFailure(f"{stage} timed out before it could start")
return remaining
def _project_root(
cwd: Path,
*,
deadline: float,
run=subprocess.run,
clock=time.monotonic,
) -> Path:
argv = ["git", "rev-parse", "--show-toplevel"]
resolved_cwd = cwd.resolve()
try:
result = run(
argv,
cwd=resolved_cwd,
capture_output=True,
text=True,
shell=False,
timeout=_remaining(deadline, clock, "repository discovery"),
)
except subprocess.TimeoutExpired as exc:
raise ValidationFailure(
f"repository discovery timed out; argv={argv!r}; "
f"cwd={str(resolved_cwd)!r}"
) from exc
if result.returncode == 0 and result.stdout.strip():
return Path(result.stdout.strip()).resolve()
return resolved_cwd
def run_validations(
root: Path,
*,
deadline: float | None = None,
clock=time.monotonic,
) -> tuple[bool, str]:
"""Build and test a discovered C/C++ project, when one exists."""
if deadline is None:
deadline = clock() + TOTAL_TIMEOUT_SECONDS
try:
config = load_config(root)
discovery = discover_project(root, config)
if discovery.selection is None:
return True, ""
selection = discovery.selection
tools = discover_toolchain(
selection.kind,
deadline=deadline,
clock=clock,
)
adapter = (
CMakeAdapter()
if selection.kind is ProjectKind.CMAKE
else MSBuildAdapter()
)
plan = adapter.create_plan(root, selection, config, tools)
child_env = os.environ.copy()
child_env[REENTRY_ENV] = "1"
execute_plan(
plan,
root,
env=child_env,
deadline=deadline,
clock=clock,
)
return True, ""
except (
ConfigError,
DiscoveryError,
ToolchainError,
AdapterError,
ValidationFailure,
OSError,
) as exc:
return False, str(exc)
def _emit_stop_response(message: str) -> None:
print(
json.dumps(
{
"continue": False,
"stopReason": message,
"systemMessage": message,
},
ensure_ascii=False,
)
)
def main(*, run=subprocess.run, clock=time.monotonic) -> int:
if os.environ.get(REENTRY_ENV) == "1":
return 0
deadline = clock() + TOTAL_TIMEOUT_SECONDS
try:
root = _project_root(
Path.cwd(),
deadline=deadline,
run=run,
clock=clock,
)
ok, message = run_validations(root, deadline=deadline, clock=clock)
except (OSError, ValidationFailure) as exc:
_emit_stop_response(f"validation hook failed: {exc}")
return 0
if ok:
return 0
_emit_stop_response(message)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,3 @@
from .config import ConfigError, load_config
__all__ = ["ConfigError", "load_config"]
@@ -0,0 +1,5 @@
from .base import AdapterError, ValidationAdapter
from .cmake import CMakeAdapter
from .msbuild import MSBuildAdapter
__all__ = ["AdapterError", "CMakeAdapter", "MSBuildAdapter", "ValidationAdapter"]
@@ -0,0 +1,19 @@
from pathlib import Path
from typing import Protocol
from ..models import HarnessConfig, ProjectSelection, Toolchain, ValidationPlan
class AdapterError(RuntimeError):
pass
class ValidationAdapter(Protocol):
def create_plan(
self,
root: Path,
selection: ProjectSelection,
config: HarnessConfig,
tools: Toolchain,
) -> ValidationPlan:
raise NotImplementedError
@@ -0,0 +1,99 @@
from pathlib import Path
from ..models import (
CommandSpec,
HarnessConfig,
ProjectKind,
ProjectSelection,
ResultCheck,
ResultCheckKind,
Toolchain,
ValidationPlan,
ValidationStep,
)
from .base import AdapterError
class CMakeAdapter:
def create_plan(
self,
root: Path,
selection: ProjectSelection,
config: HarnessConfig,
tools: Toolchain,
) -> ValidationPlan:
if selection.kind is not ProjectKind.CMAKE:
raise AdapterError("CMakeAdapter requires a CMake project selection")
if tools.cmake is None:
raise AdapterError("CMake tool is required")
if tools.ctest is None:
raise AdapterError("CTest tool is required")
if config.cmake.configure_preset is None:
command_cwd = root
binary = root / ".harness/build"
configure = (
str(tools.cmake),
"-S",
str(config.cmake.source_dir),
"-B",
str(binary),
"-A",
"x64",
)
build = (str(tools.cmake), "--build", str(binary), "--config", "Debug")
discover = (
str(tools.ctest),
"--test-dir",
str(binary),
"-C",
"Debug",
"--show-only=json-v1",
)
test = (
str(tools.ctest),
"--test-dir",
str(binary),
"-C",
"Debug",
"--output-on-failure",
)
else:
command_cwd = config.cmake.source_dir
binary = config.cmake.binary_dir
if (
binary is None
or config.cmake.build_preset is None
or config.cmake.test_preset is None
):
raise AdapterError("CMake presets require binary, build, and test settings")
configure = (str(tools.cmake), "--preset", config.cmake.configure_preset)
build = (str(tools.cmake), "--build", "--preset", config.cmake.build_preset)
discover = (
str(tools.ctest),
"--preset",
config.cmake.test_preset,
"--show-only=json-v1",
)
test = (
str(tools.ctest),
"--preset",
config.cmake.test_preset,
"--output-on-failure",
)
return ValidationPlan(
ProjectKind.CMAKE,
(
ValidationStep(
CommandSpec(configure, command_cwd, "configure"),
(ResultCheck(ResultCheckKind.CMAKE_COMPILER_IS_MSVC, binary),),
),
ValidationStep(CommandSpec(build, command_cwd, "build")),
ValidationStep(
CommandSpec(discover, command_cwd, "test-discovery"),
(ResultCheck(ResultCheckKind.CTEST_HAS_TESTS),),
),
ValidationStep(CommandSpec(test, command_cwd, "test")),
),
)
@@ -0,0 +1,60 @@
from pathlib import Path
from ..models import (
CommandSpec,
HarnessConfig,
ProjectKind,
ProjectSelection,
Toolchain,
ValidationPlan,
ValidationStep,
)
from .base import AdapterError
def _test_argv(root: Path, command: tuple[str, ...]) -> tuple[str, ...]:
if "/" not in command[0] and "\\" not in command[0]:
return command
first = Path(command[0].replace("\\", "/"))
executable = first if first.is_absolute() else (root / first).resolve()
try:
executable.relative_to(root)
except ValueError as exc:
raise AdapterError(
"msbuild.testCommand resolves outside the repository"
) from exc
return (str(executable), *command[1:])
class MSBuildAdapter:
def create_plan(
self,
root: Path,
selection: ProjectSelection,
config: HarnessConfig,
tools: Toolchain,
) -> ValidationPlan:
if selection.kind is not ProjectKind.MSBUILD:
raise AdapterError("MSBuildAdapter received a non-MSBuild project")
if not config.msbuild.test_command:
raise AdapterError(
"msbuild.testCommand is required for .sln/.vcxproj validation"
)
build = ValidationStep(
CommandSpec(
(
str(tools.msbuild),
str(selection.project_file),
"/m",
"/nologo",
f"/p:Configuration={config.msbuild.configuration}",
f"/p:Platform={config.msbuild.platform}",
),
root,
"build",
)
)
test = ValidationStep(
CommandSpec(_test_argv(root, config.msbuild.test_command), root, "test")
)
return ValidationPlan(ProjectKind.MSBUILD, (build, test))
@@ -0,0 +1,146 @@
import json
from pathlib import Path
from typing import Any
from .models import CMakeConfig, HarnessConfig, MSBuildConfig, TDDConfig
DEFAULT_TEST_PATTERNS = (
"{stem}_test.cpp",
"{stem}_tests.cpp",
"test_{stem}.cpp",
"{stem}.test.cpp",
)
class ConfigError(ValueError):
pass
def _reject_unknown(data: dict[str, Any], allowed: set[str], label: str) -> None:
unknown = sorted(set(data) - allowed)
if unknown:
raise ConfigError(f"{label} contains unexpected field: {unknown[0]}")
def _repo_path(root: Path, raw: str, label: str) -> Path:
candidate = Path(raw)
if candidate.is_absolute():
raise ConfigError(f"{label} must be repository-relative")
resolved = (root / candidate).resolve()
try:
resolved.relative_to(root)
except ValueError as exc:
raise ConfigError(f"{label} resolves outside the repository") from exc
return resolved
def _mapping(value: Any, label: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise ConfigError(f"{label} must be an object")
return value
def _nonempty_string(value: Any, label: str) -> str:
if not isinstance(value, str) or not value:
raise ConfigError(f"{label} must be a non-empty string")
return value
def _string_array(value: Any, label: str) -> tuple[str, ...]:
if not isinstance(value, list):
raise ConfigError(f"{label} must be an array")
if not value or any(not isinstance(item, str) or not item for item in value):
raise ConfigError(f"{label} must be a non-empty string array")
return tuple(value)
def load_config(root: Path) -> HarnessConfig:
root = root.resolve()
config_path = root / ".harness" / "config.json"
if config_path.is_file():
try:
data = json.loads(config_path.read_text(encoding="utf-8"))
except UnicodeDecodeError as exc:
raise ConfigError(
f"{config_path}: configuration must be valid UTF-8: {exc}"
) from exc
except json.JSONDecodeError as exc:
raise ConfigError(f"{config_path}: {exc}") from exc
else:
data = {"version": 1}
data = _mapping(data, "config")
_reject_unknown(data, {"version", "projectType", "cmake", "msbuild", "tdd"}, "config")
if data.get("version") != 1 or isinstance(data.get("version"), bool):
raise ConfigError("version must be 1")
project_type = data.get("projectType", "auto")
if project_type not in {"auto", "cmake", "msbuild"}:
raise ConfigError("projectType must be auto, cmake, or msbuild")
cmake_data = _mapping(data.get("cmake", {}), "cmake")
_reject_unknown(
cmake_data,
{"sourceDir", "binaryDir", "configurePreset", "buildPreset", "testPreset"},
"cmake",
)
source_dir = _repo_path(
root, _nonempty_string(cmake_data.get("sourceDir", "."), "cmake.sourceDir"), "cmake.sourceDir"
)
binary_dir = None
if "binaryDir" in cmake_data:
binary_dir = _repo_path(
root, _nonempty_string(cmake_data["binaryDir"], "cmake.binaryDir"), "cmake.binaryDir"
)
preset_keys = ("configurePreset", "buildPreset", "testPreset", "binaryDir")
if any(key in cmake_data for key in preset_keys) and not all(key in cmake_data for key in preset_keys):
raise ConfigError("cmake configurePreset, buildPreset, testPreset, and binaryDir must be specified together")
configure_preset = (
_nonempty_string(cmake_data["configurePreset"], "cmake.configurePreset")
if "configurePreset" in cmake_data
else None
)
build_preset = (
_nonempty_string(cmake_data["buildPreset"], "cmake.buildPreset")
if "buildPreset" in cmake_data
else None
)
test_preset = (
_nonempty_string(cmake_data["testPreset"], "cmake.testPreset")
if "testPreset" in cmake_data
else None
)
msbuild_data = _mapping(data.get("msbuild", {}), "msbuild")
_reject_unknown(msbuild_data, {"solution", "configuration", "platform", "testCommand"}, "msbuild")
solution = None
if "solution" in msbuild_data:
solution = _repo_path(
root, _nonempty_string(msbuild_data["solution"], "msbuild.solution"), "msbuild.solution"
)
configuration = _nonempty_string(msbuild_data.get("configuration", "Debug"), "msbuild.configuration")
platform = _nonempty_string(msbuild_data.get("platform", "x64"), "msbuild.platform")
test_command = (
_string_array(msbuild_data["testCommand"], "msbuild.testCommand")
if "testCommand" in msbuild_data
else None
)
tdd_data = _mapping(data.get("tdd", {}), "tdd")
_reject_unknown(tdd_data, {"testRoots", "testPatterns", "exclude"}, "tdd")
raw_test_roots = _string_array(tdd_data["testRoots"], "tdd.testRoots") if "testRoots" in tdd_data else ("tests", "test")
test_roots = tuple(_repo_path(root, item, "tdd.testRoots") for item in raw_test_roots)
test_patterns = _string_array(tdd_data["testPatterns"], "tdd.testPatterns") if "testPatterns" in tdd_data else DEFAULT_TEST_PATTERNS
if any("{stem}" not in pattern for pattern in test_patterns):
raise ConfigError("every tdd.testPatterns entry must contain {stem}")
exclude = _string_array(tdd_data["exclude"], "tdd.exclude") if "exclude" in tdd_data else ()
for item in exclude:
_repo_path(root, item, "tdd.exclude")
return HarnessConfig(
version=1,
project_type=project_type,
cmake=CMakeConfig(source_dir, binary_dir, configure_preset, build_preset, test_preset),
msbuild=MSBuildConfig(solution, configuration, platform, test_command),
tdd=TDDConfig(test_roots, test_patterns, exclude),
)
@@ -0,0 +1,77 @@
from pathlib import Path
from .models import DiscoveryResult, HarnessConfig, ProjectKind, ProjectSelection
from .tdd_policy import find_cpp_files
class DiscoveryError(RuntimeError):
pass
def _cmake_entry(source: Path) -> Path | None:
for name in ("CMakePresets.json", "CMakeUserPresets.json", "CMakeLists.txt"):
candidate = source / name
if candidate.is_file():
return candidate
return None
def _single_msbuild(root: Path, explicit: Path | None) -> Path:
if explicit is not None:
if explicit.is_file() and explicit.suffix.lower() in {".sln", ".vcxproj"}:
return explicit
raise DiscoveryError(f"configured MSBuild project is invalid: {explicit}")
solutions = sorted(root.glob("*.sln"))
if len(solutions) > 1:
raise DiscoveryError("multiple .sln files found; set msbuild.solution")
if solutions:
return solutions[0]
projects = sorted(root.glob("*.vcxproj"))
if len(projects) > 1:
raise DiscoveryError("multiple .vcxproj files found; set msbuild.solution")
if projects:
return projects[0]
raise DiscoveryError("no root .sln or .vcxproj was found")
def discover_project(root: Path, config: HarnessConfig) -> DiscoveryResult:
root = root.resolve()
if config.project_type == "cmake":
entry = _cmake_entry(config.cmake.source_dir)
if entry is None:
raise DiscoveryError(
f"no CMake project found in {config.cmake.source_dir}"
)
return DiscoveryResult(ProjectSelection(ProjectKind.CMAKE, entry))
if config.project_type == "msbuild":
project = _single_msbuild(root, config.msbuild.solution)
return DiscoveryResult(ProjectSelection(ProjectKind.MSBUILD, project))
entry = _cmake_entry(root)
if entry is not None:
return DiscoveryResult(ProjectSelection(ProjectKind.CMAKE, entry))
solutions = sorted(root.glob("*.sln"))
if len(solutions) > 1:
raise DiscoveryError("multiple .sln files found; set msbuild.solution")
if solutions:
return DiscoveryResult(
ProjectSelection(ProjectKind.MSBUILD, solutions[0])
)
projects = sorted(root.glob("*.vcxproj"))
if len(projects) > 1:
raise DiscoveryError("multiple .vcxproj files found; set msbuild.solution")
if projects:
return DiscoveryResult(
ProjectSelection(ProjectKind.MSBUILD, projects[0])
)
cpp_files = find_cpp_files(root)
if cpp_files:
raise DiscoveryError(
"C/C++ files exist but no CMakeLists.txt, preset, .sln, or .vcxproj "
"was found; configure projectType and project path"
)
return DiscoveryResult(None, ())
@@ -0,0 +1,100 @@
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
class ProjectKind(Enum):
CMAKE = "cmake"
MSBUILD = "msbuild"
class ResultCheckKind(Enum):
CMAKE_COMPILER_IS_MSVC = "cmake_compiler_is_msvc"
CTEST_HAS_TESTS = "ctest_has_tests"
@dataclass(frozen=True)
class CMakeConfig:
source_dir: Path
binary_dir: Path | None = None
configure_preset: str | None = None
build_preset: str | None = None
test_preset: str | None = None
@dataclass(frozen=True)
class MSBuildConfig:
solution: Path | None = None
configuration: str = "Debug"
platform: str = "x64"
test_command: tuple[str, ...] | None = None
@dataclass(frozen=True)
class TDDConfig:
test_roots: tuple[Path, ...]
test_patterns: tuple[str, ...]
exclude: tuple[str, ...] = ()
@dataclass(frozen=True)
class HarnessConfig:
version: int
project_type: str
cmake: CMakeConfig
msbuild: MSBuildConfig
tdd: TDDConfig
@dataclass(frozen=True)
class ProjectSelection:
kind: ProjectKind
project_file: Path
@dataclass(frozen=True)
class DiscoveryResult:
selection: ProjectSelection | None
cpp_files: tuple[Path, ...] = ()
@dataclass(frozen=True)
class Toolchain:
installation: Path
msbuild: Path
cmake: Path | None = None
ctest: Path | None = None
@dataclass(frozen=True)
class CommandSpec:
argv: tuple[str, ...]
cwd: Path
stage: str
timeout_seconds: int = 1800
@dataclass(frozen=True)
class ResultCheck:
kind: ResultCheckKind
path: Path | None = None
@dataclass(frozen=True)
class ValidationStep:
command: CommandSpec
checks: tuple[ResultCheck, ...] = ()
@dataclass(frozen=True)
class ValidationPlan:
project_kind: ProjectKind
steps: tuple[ValidationStep, ...]
@dataclass(frozen=True)
class CommandResult:
command: CommandSpec
returncode: int
stdout: str
stderr: str
@@ -0,0 +1,140 @@
import json
import locale
import re
import subprocess
import time
from .models import CommandResult, ResultCheckKind
DIAGNOSTIC_LIMIT = 8000
class ValidationFailure(RuntimeError):
pass
def _decode(value):
if value is None:
return ""
if isinstance(value, str):
return value
for encoding in ("utf-8", locale.getpreferredencoding(False)):
try:
return value.decode(encoding)
except (UnicodeDecodeError, LookupError):
continue
return value.decode("utf-8", errors="replace")
def _check_result(check, result):
if check.kind is ResultCheckKind.CMAKE_COMPILER_IS_MSVC:
if check.path is None:
raise ValidationFailure("MSVC check is missing binaryDir")
files = sorted(check.path.glob("CMakeFiles/*/CMakeCXXCompiler.cmake"))
if not files:
raise ValidationFailure("CMake did not generate compiler metadata")
text = files[-1].read_text(encoding="utf-8", errors="replace")
if not re.search(
r'set\s*\(\s*CMAKE_CXX_COMPILER_ID\s+"MSVC"\s*\)', text
):
raise ValidationFailure("CMake selected a compiler other than MSVC")
elif check.kind is ResultCheckKind.CTEST_HAS_TESTS:
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise ValidationFailure("CTest discovery did not return JSON") from exc
if not isinstance(payload, dict):
raise ValidationFailure("CTest discovery JSON must be an object")
if not isinstance(payload.get("tests"), list) or not payload["tests"]:
raise ValidationFailure("CTest discovered no tests")
def _diagnostic(stdout, stderr):
diagnostic = (stdout + "\n" + stderr).rstrip()[-DIAGNOSTIC_LIMIT:]
return diagnostic.replace("\r\n", " | ").replace("\n", " | ")
def _command_context(argv, cwd):
return f"argv={list(argv)!r}; cwd={str(cwd)!r}"
def execute_plan(
plan,
root,
*,
env=None,
total_timeout_seconds=1800,
deadline=None,
run=subprocess.run,
clock=time.monotonic,
):
root = root.resolve()
if deadline is None:
deadline = clock() + total_timeout_seconds
results = []
for step in plan.steps:
cwd = step.command.cwd.resolve()
context = _command_context(step.command.argv, cwd)
try:
cwd.relative_to(root)
except ValueError as exc:
raise ValidationFailure(
f"{step.command.stage} working directory is outside the repository; "
f"{context}"
) from exc
remaining = deadline - clock()
if remaining <= 0:
raise ValidationFailure(
f"{step.command.stage} timed out before it could start; {context}"
)
timeout = min(step.command.timeout_seconds, remaining)
try:
completed = run(
list(step.command.argv),
cwd=cwd,
env=env,
capture_output=True,
shell=False,
timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
stdout = _decode(exc.output)
stderr = _decode(exc.stderr)
diagnostic = _diagnostic(stdout, stderr)
message = (
f"{step.command.stage} timed out after {timeout} seconds; {context}"
)
if diagnostic:
message = f"{message}; output tail: {diagnostic}"
raise ValidationFailure(message) from exc
stdout = _decode(completed.stdout)
stderr = _decode(completed.stderr)
result = CommandResult(step.command, completed.returncode, stdout, stderr)
if completed.returncode != 0:
diagnostic = _diagnostic(stdout, stderr)
message = (
f"{step.command.stage} failed with exit code {completed.returncode}; "
f"{context}"
)
if diagnostic:
message = f"{message}; output tail: {diagnostic}"
raise ValidationFailure(message)
try:
for check in step.checks:
_check_result(check, result)
except ValidationFailure as exc:
diagnostic = _diagnostic(stdout, stderr)
message = (
f"{step.command.stage} result check failed: {exc}; {context}"
)
if diagnostic:
message = f"{message}; output tail: {diagnostic}"
raise ValidationFailure(message) from exc
results.append(result)
return tuple(results)
@@ -0,0 +1,88 @@
from fnmatch import fnmatch
from pathlib import Path
CPP_SUFFIXES = frozenset({".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".hxx"})
DEFAULT_EXCLUDES = (
".harness/build/**",
"build/**",
"out/**",
"cmake-build-*/**",
"third_party/**",
"external/**",
"vendor/**",
"generated/**",
)
def is_excluded_path(path: Path, root: Path, extra: tuple[str, ...] = ()) -> bool:
try:
relative = path.resolve().relative_to(root.resolve()).as_posix()
except ValueError:
return False
return any(fnmatch(relative, pattern) for pattern in DEFAULT_EXCLUDES + extra)
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))
def is_test_file(path: Path, root: Path, config) -> bool:
resolved = path.resolve()
if any(root == resolved or root in resolved.parents for root in config.test_roots):
return True
relative = resolved.relative_to(root.resolve())
if {"test", "tests"} & {part.lower() for part in relative.parts}:
return True
stem = resolved.stem.lower()
return stem.startswith("test_") or stem.endswith(("_test", "_tests", ".test"))
def matching_test_exists(path: Path, config) -> bool:
names = tuple(pattern.format(stem=path.stem) for pattern in config.test_patterns)
roots = (
*config.test_roots,
path.parent / "tests",
path.parent / "test",
)
for root in roots:
if not root.is_dir():
continue
for name in names:
if any(candidate.is_file() for candidate in root.rglob(name)):
return True
return False
def evaluate_paths(paths, root: Path, config) -> str | None:
root = root.resolve()
for raw in paths:
path = raw.resolve()
try:
path.relative_to(root)
except ValueError:
return f"TDD GUARD: '{path}' is outside the repository"
if path.suffix.lower() not in CPP_SUFFIXES:
continue
if (
path.name.lower() == "main.cpp"
or is_excluded_path(path, root, config.exclude)
or is_test_file(path, root, config)
):
continue
if not matching_test_exists(path, config):
expected = config.test_patterns[0].format(stem=path.stem)
return (
f"TDD GUARD: '{path.name}' requires an existing test such as "
f"'{expected}'. Add the test in a configured test root first."
)
return None
@@ -0,0 +1,169 @@
import os
import shutil
import subprocess
import time
from pathlib import Path
from .models import ProjectKind, Toolchain
VC_WORKLOAD = "Microsoft.VisualStudio.Component.VC.Tools.x86.x64"
VS_CMAKE_RELATIVE = Path(
"Common7/IDE/CommonExtensions/Microsoft/CMake/CMake/bin"
)
class ToolchainError(RuntimeError):
pass
def _decode(value):
if isinstance(value, str):
return value
return (value or b"").decode("utf-8", errors="replace")
def _first_path(result, label, argv, cwd):
text = _decode(result.stdout).strip()
if result.returncode != 0:
diagnostic = (
_decode(result.stdout) + "\n" + _decode(result.stderr)
).rstrip()[-8000:]
diagnostic = diagnostic.replace("\r\n", " | ").replace("\n", " | ")
message = (
f"{label} probe failed with exit code {result.returncode}; "
f"argv={argv!r}; cwd={str(cwd)!r}"
)
if diagnostic:
message = f"{message}; output tail: {diagnostic}"
raise ToolchainError(
f"{message}; install Visual Studio Desktop development with C++"
)
if not text:
raise ToolchainError(
f"{label} probe returned no path; argv={argv!r}; cwd={str(cwd)!r}; "
"install Visual Studio Desktop development with C++"
)
path = Path(text.splitlines()[0]).resolve()
if not path.exists():
diagnostic = (
_decode(result.stdout) + "\n" + _decode(result.stderr)
).rstrip()[-8000:]
diagnostic = diagnostic.replace("\r\n", " | ").replace("\n", " | ")
message = (
f"{label} probe returned nonexistent path with exit code 0; "
f"argv={argv!r}; cwd={str(cwd)!r}"
)
if diagnostic:
message = f"{message}; output tail: {diagnostic}"
raise ToolchainError(
f"{message}; install or repair Visual Studio Desktop development with C++"
)
return path
def _require_path(path, label):
if path is None or not path.exists():
raise ToolchainError(f"{label} not found")
return path.resolve()
def _remaining(deadline, clock, label):
if deadline is None:
return None
remaining = deadline - clock()
if remaining <= 0:
raise ToolchainError(f"{label} probe timed out before it could start")
return remaining
def _probe(run, argv, label, *, deadline, clock):
cwd = Path.cwd().resolve()
kwargs = {
"cwd": cwd,
"capture_output": True,
"check": False,
"shell": False,
}
remaining = _remaining(deadline, clock, label)
if remaining is not None:
kwargs["timeout"] = remaining
try:
return run(argv, **kwargs)
except subprocess.TimeoutExpired as exc:
raise ToolchainError(
f"{label} probe timed out; argv={argv!r}; cwd={str(cwd)!r}; "
"install or repair Visual Studio Desktop development with C++"
) from exc
def discover_toolchain(
kind: ProjectKind,
*,
env=None,
which=shutil.which,
run=subprocess.run,
deadline=None,
clock=time.monotonic,
) -> Toolchain:
environment = os.environ if env is None else env
vswhere_path = which("vswhere.exe")
if vswhere_path is None:
program_files = environment.get("ProgramFiles(x86)")
if program_files:
candidate = Path(program_files) / "Microsoft Visual Studio/Installer/vswhere.exe"
vswhere_path = str(candidate) if candidate.exists() else None
vswhere = _require_path(
Path(vswhere_path) if vswhere_path else None,
"vswhere.exe; install Visual Studio Desktop development with C++",
)
install_argv = [
str(vswhere), "-latest", "-products", "*", "-requires", VC_WORKLOAD,
"-property", "installationPath",
]
install_result = _probe(
run,
install_argv,
"Visual Studio installation",
deadline=deadline,
clock=clock,
)
probe_cwd = Path.cwd().resolve()
installation = _require_path(
_first_path(
install_result,
"Visual Studio installation",
install_argv,
probe_cwd,
),
"Visual Studio installation",
)
msbuild_argv = [
str(vswhere), "-latest", "-products", "*", "-requires", VC_WORKLOAD,
"-find", r"MSBuild\**\Bin\MSBuild.exe",
]
msbuild_result = _probe(
run,
msbuild_argv,
"MSBuild",
deadline=deadline,
clock=clock,
)
msbuild = _require_path(
_first_path(msbuild_result, "MSBuild", msbuild_argv, probe_cwd),
"MSBuild.exe",
)
if kind is not ProjectKind.CMAKE:
return Toolchain(installation=installation, msbuild=msbuild)
bundled_cmake_dir = installation / VS_CMAKE_RELATIVE
cmake = _require_path(
Path(which("cmake.exe")) if which("cmake.exe") else bundled_cmake_dir / "cmake.exe",
"cmake.exe",
)
ctest = _require_path(
Path(which("ctest.exe")) if which("ctest.exe") else bundled_cmake_dir / "ctest.exe",
"ctest.exe",
)
return Toolchain(installation=installation, msbuild=msbuild, cmake=cmake, ctest=ctest)
@@ -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이 없으면 발견된 문제가 없다고 쓰고
남은 검증 공백을 밝힌다.
+28 -25
View File
@@ -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`과 프로젝트 문서를
우선한다.
+18 -15
View File
@@ -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 성공은
검증되지 않았다.
+25 -13
View File
@@ -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와 배포 범위는 요구사항이 정해질 때까지 확정하지 않는다.
@@ -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 "
@@ -0,0 +1,145 @@
---
name: harness
description: Use when planning agentic implementation phases, creating phases/index.json and self-contained step files, or running the Harness step executor.
---
# Harness Workflow
Harness는 Windows에서 C#·.NET 8 제품 개발을 지원하는 Python 계획·실행 도구다.
제품 요구사항과 기술 결정을 Harness 자체 요구사항과 섞지 않는다. 제품 유형과 계층을
가정하지 않으며 신규 테스트 기본값은 xUnit v2 + VSTest다.
## 탐색과 논의
1. `AGENTS.md`를 읽는다.
2. 변경 범위와 직접 관련된 제품 문서, 코드, 테스트와 `.harness/config.json`만 읽는다.
3. 미정인 제품 요구사항이나 공개 계약이 구현 결과를 바꾸면 사용자와 논의한다.
전체 `docs/`를 일괄 읽거나 그 본문을 step에 복사하지 않는다. 이전 대화 없이도 실행할
수 있도록 필요한 기존 파일을 명시적으로 참조한다.
## Step 설계
하나의 step에는 다음 요소만 필수로 둔다.
- 목표와 범위
- 관련 파일과 `context_files`
- 핵심 제약
- 실행 가능한 Acceptance Criteria
공개 API 계약이 중요한 경우에만 시그니처와 nullability를 적는다. 새 기능과 버그 수정은 한
step 안에서 실패 테스트 확인, 최소 구현, 성공 확인까지 끝낸다. 실제 설정과 기존
테스트 명령을 우선하며 확인되지 않은 예시 명령을 AC로 쓰지 않는다.
제품 검증은 저장소 루트에서 `global.json`에 따른 SDK 선택을 확인한다. version 2
설정의 target에 포함된 각 프로젝트와 모든 `testProjects`에서 SDK로 평가한 참조를
끝까지 따라가 실제 TFM이 .NET 8이고 프로젝트가 저장소 내부인지 확인한다.
이후 target과 모든 `testProjects`를 restore/build한 뒤 각 테스트를
실행한다. 경고는 오류로 처리하고 현재 TRX의 누락·실행 0건·skip-only를 성공으로
기록하지 않는다. 제품에서 테스트 프로젝트로 향하는 참조를 만들지 않는다.
세부 명령은 [환경 구축 가이드](../../../docs/ENVIRONMENT_SETUP.md)를 따른다.
`Foo.cs`에는 기본적으로 `FooTests.cs` 또는 `FooTest.cs`가 필요하다. 파일 존재 검사는
실제 실패 테스트와 성공 실행을 증명하지 않으므로 명령·결과를 step 기록에 남긴다.
`Program.cs`도 자동 면제가 아니다. 필요한 면제는 정확한 `tdd.exclude` 경로와 이유를
기록하며 제품 검증 대상에서 파일을 숨기는 용도로 사용하지 않는다.
## Index 형식
`phases/index.json`:
```json
{
"phases": [{"dir": "add-parser", "status": "pending"}]
}
```
`phases/{phase}/index.json`:
```json
{
"project": "<제품명 또는 미정>",
"phase": "add-parser",
"steps": [
{
"step": 0,
"name": "implement-parser",
"status": "pending",
"context_files": ["docs/ARCHITECTURE.md"],
"validation": "full"
}
]
}
```
- `name`은 kebab-case slug다.
- `context_files`는 선택 사항인 저장소 상대 파일 경로 배열이다. glob과 URL은 쓰지
않는다. 생략하거나 빈 배열이면 추가 문서를 주입하지 않는다.
- `validation`은 `full` 또는 `docs-only`이며 생략 시 `full`이다. `docs-only`는 실제
변경이 허용된 문서 경로에만 있을 때만 제품 검증 생략을 요청한다.
- 생성 시 status는 `pending`이다. timestamp는 executor가 기록한다.
상태 기록:
| 상태 | Codex 기록 | Executor 기록 |
|---|---|---|
| `completed` | 한 줄 `summary` | `completed_at` |
| `error` | `error_message` | `failed_at` |
| `blocked` | `blocked_reason` | `blocked_at` |
## Step 파일
````markdown
# Step N: <name>
## 목표와 범위
<한 가지 책임과 범위 밖 항목>
## 관련 파일
- `<path>`
## 핵심 제약
- <유지할 계약과 이유>
## Acceptance Criteria
```powershell
<실제 build/test 명령>
```
````
현재 step보다 앞선 작업이 만든 파일은 관련 파일에 적고 실행 중 직접 읽는다. 이미
존재하는 제품 문서만 `context_files`에 넣는다.
## 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
```
`--dry-run`은 첫 pending step의 주입 경로와 문자 수, 전체 프롬프트 문자 수만
출력하며 파일, Git, 상태와 Codex를 변경하지 않는다. `--push`와 함께 쓸 수 없다.
프롬프트는 `AGENTS → 공통 규칙 → context_files → 최신 handoff 또는 호환 summary →
직전 실패 → 현재 step` 순서다. handoff가 없을 때만 현재 step보다 앞선 완료 step 중
가장 최근 하나의 summary를 최대 1,000자로 전달한다.
Executor는 최초 호출을 포함해 Codex를 최대 3회 호출한다. 재시도 오류 본문은 총
4,000자 안에서 앞 1,500자, 중간 생략 marker와 끝부분 최대 2,500자를 보존한다. 원본 출력은
`phases/{phase}/stepN-output.json`에 온전히 쓰지만 다음 호출에서 덮어쓴다. 지속할
시행착오는 `docs/phase/{phase}/stepN.md`에 남긴다.
`completed`는 Codex 프로세스 exit code가 0이고 executor의 독립 제품 검증까지
통과해야 성공이다. Stop이 실패 응답으로 턴을 멈춰도 CLI는 exit 0일 수 있으므로
모델이 쓴 상태만 신뢰하지 않는다. 독립 검증 실패도 같은 3회 호출 예산에서 재시도한다.
`docs-only`는 이 검사에서도 실제 문서 변경을 확인하며 `blocked`는 즉시 멈춘다.
`error`나 `blocked`를 재개하려면 원인을 해결하고 status를 `pending`으로 바꾼 뒤 해당
오류 필드와 timestamp를 제거한다.
@@ -0,0 +1,38 @@
---
name: review
description: Use when reviewing repository changes against AGENTS.md, architecture decisions, tests, and build requirements.
---
# Repository Review
`AGENTS.md`를 먼저 읽고 변경 범위와 직접 관련된 제품 문서, 코드와 테스트만 추가로
읽는다. `context_files`가 있으면 해당 목록을 우선한다. 전체 `docs/`를 일괄 읽도록
요구하지 않는다.
사용자가 범위를 지정하지 않으면 현재 작업 트리의 변경을 리뷰한다. 실제 결함을
심각도순으로 제시하고 각 finding에 파일과 줄 번호, 영향, 근거와 수정 방안을 적는다.
다음을 해당 변경에 적용할 수 있는 범위에서 확인한다.
- 공개 C# API의 접근성·호환성·nullability 계약, 프로젝트 참조 방향과 관련 ADR
- 제품 프로젝트가 테스트 프로젝트를 참조하지 않는지 확인
- 새 C# 동작을 먼저 재현한 실패 테스트의 명령·결과와 구현 후 회귀 검증
- 실제 `global.json`, `.sln` / SDK 스타일 `.csproj`, version 2 `.harness/config.json`에
따른 .NET 8 SDK 선택, 각 제품·테스트 프로젝트의 실제 TFM 평가와 restore/build/test 결과;
상속된 props와 속성 참조를 포함한 `TargetFramework`/`TargetFrameworks`도 확인
- `testProjects`가 전체 제품 테스트를 포함하고 각 프로젝트의 현재 TRX에 실행된
테스트가 있는지 확인; 테스트 0건·skip-only·누락·과거 결과는 성공 근거가 아니다
- nullable 진단, 경고의 오류 처리와 범위 밖 변경
- async 코드 변경 시 취소·예외 전파, `IDisposable` / `IAsyncDisposable` 사용 변경 시
자원 해제 확인; 모든 제품에 async나 DI를 요구하지 않는다
- C# 대응 테스트 파일 존재를 red-green 증거로 혼동하지 않는지, `Program.cs` 등의
면제가 정확한 경로와 이유로 한정되고 제품 검증을 우회하지 않는지 확인
- Harness 변경이면 관련 Python 테스트와 운영 문서의 필드·동작 일치
명시적으로 `validation: docs-only`인 문서 step은 실제 변경이 허용된 문서 경로에만
있을 때 제품 build를 생략할 수 있다. 그 외에는 기존 전체 검증을 사용한다. 실행하지
못한 검사는 성공으로 추정하지 않고 `미검증`과 이유를 기록한다.
finding 뒤에는 아키텍처, 기술 스택, 테스트, 필수 규칙, build 가능 여부를 각각
`통과`, `실패`, `미검증`으로 요약한다. finding이 없으면 발견된 문제가 없다고 쓰고
남은 검증 공백을 밝힌다.
@@ -0,0 +1,32 @@
{
"description": "Harness TDD, command safety, and .NET 8 C# validation hooks on Windows.",
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|shell_command|PowerShell|exec_command|functions.exec_command|apply_patch|functions.apply_patch|Edit|MultiEdit|Write",
"hooks": [
{
"type": "command",
"command": "python3 -X utf8 \"$(git rev-parse --show-toplevel)/scripts/hooks/pre_tool_use.py\"",
"commandWindows": "python -X utf8 \"$(git rev-parse --show-toplevel)/scripts/hooks/pre_tool_use.py\"",
"timeout": 30,
"statusMessage": "Checking Harness policies"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 -X utf8 \"$(git rev-parse --show-toplevel)/scripts/hooks/stop_validation.py\"",
"commandWindows": "python -X utf8 \"$(git rev-parse --show-toplevel)/scripts/hooks/stop_validation.py\"",
"timeout": 1800,
"statusMessage": "Running .NET 8 build and tests"
}
]
}
]
}
}
@@ -0,0 +1,9 @@
__pycache__/
.pytest_cache/
*.py[cod]
.harness/build/
bin/
obj/
.vs/
TestResults/
.harness/test-results/
@@ -0,0 +1,19 @@
{
"version": 2,
"projectType": "dotnet",
"dotnet": {
"target": "Example.sln",
"configuration": "Debug",
"testProjects": ["tests/Example.Tests/Example.Tests.csproj"]
},
"tdd": {
"testRoots": [
"tests"
],
"testPatterns": [
"{stem}Tests.cs",
"{stem}Test.cs"
],
"exclude": []
}
}
@@ -0,0 +1,65 @@
# C# .NET 8 제품 개발 프로젝트
이 저장소는 **C#·.NET 8 제품 개발용 범용 템플릿**이다. Harness와 `scripts/`의 Python
코드는 제품 개발을 지원하는 도구이며 제품의 목표나 기능이 아니다.
## 현재 확정 상태
- 제품명·사용자·업무 기능·UI·배포 방식: 미정
- 제품: C#, 기본 TFM `net8.0`, SDK 선택은 루트 `global.json`
- Harness 실행·검증 환경: Windows, Python 3.10 이상
- 신규 제품 테스트 기본값: xUnit v2 + VSTest
- 빌드 진입점과 전체 테스트 목록: 실제 `.sln` / SDK 스타일 `.csproj`와
`.harness/config.json` version 2로 확인
- 권장 배치: 제품 `src/<ProjectName>/`, 테스트 `tests/<ProjectName>.Tests/`
- `tests/fixtures/`는 Harness 검증용 예제이며 실제 제품이 아니다.
미정 값을 예시로 채우거나 검증된 사실처럼 표현하지 않는다. 특정 UI, 계층, DI나
async 구조를 필요 없이 강제하지 않는다.
## 필수 규칙
- CRITICAL: 공개 API 계약과 프로젝트 참조 방향을 유지한다. 제품 프로젝트에서
테스트 프로젝트를 참조하지 않는다.
- CRITICAL: 새 기능과 버그 수정은 실패하는 테스트를 먼저 확인한 뒤 구현한다.
편집 훅의 대응 테스트 파일 존재 검사는 실제 red-green 증거를 대신하지 않는다.
- CRITICAL: 새 빌드 경고를 추가하지 않는다. 빌드는 경고를 오류로 처리한다.
- 신규 프로젝트는 nullable 검사와 implicit usings를 활성화한다. 언어 버전은
SDK 기본값을 사용하며 `latest`를 지정하지 않는다.
- 변경 범위와 관련된 문서·코드·테스트만 읽고 수정한다. step의 `context_files`가
있으면 그 목록을 우선한다.
- 실행 상태는 `phases/{phase}/index.json`을 기준으로 판단한다. 인계 문서는 상태를
대신하지 않는다.
- phase 작업 시 `docs/phase/{phase}/handoff.md`를 확인하고, 종료 전에 최신 인계와
해당 `stepN.md` 기록을 갱신한다. 상세 양식은 [docs/phase/README.md](docs/phase/README.md)를 따른다.
- 커밋 메시지는 conventional commits 형식을 따른다.
## 검증
실제 설정과 프로젝트 문서를 우선한다. 아래 제품 경로는 예시이므로 실제 target과
모든 `testProjects`로 바꾼다. 저장소 루트에서 SDK 확인 → 실제 TFM 평가 → restore →
build → test를 실행한다. target에 포함된 각 프로젝트와 모든 test project에서 평가된
참조를 끝까지 따라가 `TargetFramework`/`TargetFrameworks`가 .NET 8이고 프로젝트가
저장소 내부인지 확인한다. 각 test project의 현재 TRX
결과와 실행 건수를 확인한다. 테스트 0건,
skip-only, 결과 누락은 성공이 아니다. 대상 없는 템플릿도 제품 빌드 성공이 아니다.
```powershell
dotnet --version
dotnet msbuild <each-project.csproj> -getProperty:TargetFramework,TargetFrameworks -getItem:ProjectReference -p:Configuration=Debug
dotnet restore <target.sln-or-csproj> -p:Configuration=Debug
dotnet restore <test-project.csproj> -p:Configuration=Debug
dotnet build <target.sln-or-csproj> -c Debug --no-restore -warnaserror
dotnet build <test-project.csproj> -c Debug --no-restore -warnaserror
dotnet test <test-project.csproj> -c Debug --no-build --no-restore --logger trx
```
Harness 자체 변경은 별도로 검증한다.
```powershell
uv run --with pytest python -m pytest tests/harness -v -rs
uv run --with pytest python -m compileall -q scripts tests/harness
```
설정·결과 판정은 [Harness 운영 가이드](docs/HARNESS.md), Windows 설치와 첫 실행은
[환경 구축 가이드](docs/ENVIRONMENT_SETUP.md)를 따른다.
@@ -0,0 +1,56 @@
# Architecture Decision Records
제품명, 사용자, 업무 기능, UI와 배포 구조는 미정이다. 아래 결정은 제품 기술 경계와
신규 프로젝트 기본값을 정하며 구체적인 도메인 설계를 대신하지 않는다.
## ADR-001: MSVC 기반 C++ 제품
- **상태:** 대체됨 (superseded by ADR-003)
- **이전 결정:** 대상 제품을 MSVC 기반 C++로 개발한다.
- **대체 이유:** 사용자가 C# 전용 .NET 8 템플릿 전환을 승인했다.
- **결과:** 이전 C++·MSVC 지원과 호환 계층을 유지하지 않는다.
## ADR-002: public header 경계
- **상태:** 대체됨 (superseded by ADR-004)
- **이전 결정:** 공개 header와 implementation의 의존성 방향을 유지하고
`src/`, `include/`, `tests/`로 배치한다.
- **대체 이유:** C#의 공개 API 접근성과 프로젝트 참조로 경계를 표현한다.
## ADR-003: C#·.NET 8 범용 제품 템플릿
- **상태:** 승인
- **결정:** 제품 언어는 C#, 신규 제품 기본 TFM은 `net8.0`이다.
`global.json`은 SDK `8.0.424`, `rollForward=latestPatch`,
`allowPrerelease=false`를 기준으로 한다.
- **이유:** 승인된 기술 전환 방향과 설치가 확인된 SDK 기준을 명시한다. 해당 SDK를
최신 버전이라고 주장하지 않는다.
- **대안·trade-off:** 다중 언어 호환 계층과 .NET Framework 4.x 지원은 제외한다.
SDK 패치 갱신과 향후 런타임 전환은 별도로 검증한다.
- **영향 경로:** `global.json`, 신규 제품 `.csproj`, `AGENTS.md`, 제품 문서.
## ADR-004: 공개 API와 프로젝트 참조 경계
- **상태:** 승인
- **결정:** 공개 API의 접근성·nullability 계약과 제품이 정한 참조 방향을 유지한다.
제품 프로젝트는 테스트 프로젝트를 참조하지 않는다. 권장 배치는
`src/<ProjectName>/`와 `tests/<ProjectName>.Tests/`이다.
- **이유:** 제품 소비자가 구현 세부사항이나 테스트 의존성을 부담하지 않도록 한다.
- **대안·trade-off:** 별도 Contracts, DI와 계층을 템플릿에 미리 만들지 않는다.
공개 계약 분리가 필요해질 때 제품 요구에 따라 결정한다.
- **영향 경로:** 신규 제품·테스트 `.csproj`, 공개 C# API, `docs/ARCHITECTURE.md`.
## ADR-005: 신규 프로젝트의 테스트와 컴파일 기본값
- **상태:** 승인
- **결정:** 신규 테스트는 xUnit v2 + VSTest를 기본으로 하고, nullable와 implicit
usings를 활성화한다. C# 언어 버전은 SDK 기본값을 사용하고 빌드 경고는 오류로 처리한다.
- **이유:** 실제 실행된 테스트와 컴파일 진단을 기본 검증 근거로 삼는다.
- **대안·trade-off:** 기존 테스트 프레임워크는 VSTest/TRX 계약을 만족하면 사용할 수
있다. MTP 전용 어댑터, 대규모 스타일 규칙과 커버리지 임계값은 도입하지 않는다.
- **영향 경로:** 신규 제품·테스트 `.csproj`, `AGENTS.md`.
## 새 결정 형식
새 ADR에는 상태, 결정, 이유, 대안 또는 trade-off, 영향을 받는 경로를 기록한다.
Harness 사용법과 실행 상태는 제품 ADR 대신 Harness 문서와 phase index에 기록한다.
@@ -0,0 +1,25 @@
# 제품 아키텍처
## 확정된 기술 경계
제품은 C#·.NET 8을 사용한다. 실제 업무 프로젝트, entry point, 런타임 데이터 흐름과
배포 구조는 아직 미정이다. 특정 UI 프레임워크, 계층, DI나 async 구조를 강제하지 않는다.
다음은 제품 생성 시의 **권장 배치**이며 현재 구현된 제품 구조를 뜻하지 않는다.
```text
src/<ProjectName>/ 제품 C# 코드와 SDK 스타일 .csproj
tests/<ProjectName>.Tests/ 제품 테스트와 테스트 .csproj
```
- 공개 API의 접근성, 타입과 nullability 계약을 유지한다.
- 프로젝트 참조는 제품에서 정한 방향을 따른다. 테스트가 제품을 참조하며 제품은
테스트 프로젝트를 참조하지 않는다.
- 별도 Contracts 프로젝트나 추가 계층은 실제 필요가 확인될 때만 도입한다.
- 신규 프로젝트는 `TargetFramework=net8.0`, `Nullable=enable`,
`ImplicitUsings=enable`을 기본으로 한다. C# 언어 버전은 SDK 기본값을 따른다.
- 루트 `global.json`은 빌드에 사용할 SDK 선택을 제어하며 제품 TFM을 대신하지 않는다.
실제 제품과 metadata가 추가되면 확인된 구조와 참조 방향만 이 문서에 기록한다.
`tests/fixtures/dotnet/Smoke/`는 개발 도구 검증용이며 제품이 아니다. Harness의 Python
모듈 구조와 phase 운영 절차는 [Harness 문서](HARNESS.md)에 둔다.
@@ -0,0 +1,354 @@
# C# .NET 8 Harness 환경 구축 가이드
이 문서는 Windows에서 Harness를 설치하고 C#·.NET 8 제품의 빌드·테스트와 Codex
step 실행을 준비하는 절차를 설명한다. 명령은 별도 표시가 없으면 **대상 저장소
루트의 PowerShell**에서 실행한다.
이 저장소는 범용 템플릿이며 제품명·업무 기능·UI·배포 방식은 미정이다.
`tests/fixtures/dotnet/Smoke/`는 도구 검증용 프로젝트다. 아래 `Example` 경로는
제품 설정 예시이며 현재 제품이나 검증 성공을 뜻하지 않는다. 실제 검증 결과는
해당 [phase 기록](phase/README.md)에 남긴다.
## 1. 필요한 환경
| 구분 | 요구 사항 | 용도 및 확인 기준 |
|---|---|---|
| 운영체제 | Windows | Harness 실행·검증 환경 고정. 제품 배포 OS와 별개다. |
| 셸 | PowerShell | 아래 UTF-8 파일 작성 예제는 PowerShell 7 기준이다. |
| Git | Windows에서 실행 가능한 Git | 저장소 탐색, branch 전환, 자동 commit. `--push` 시 원격 인증이 필요하다. |
| Python | 3.10 이상, 실제 `python` 명령 실행 가능 | 실행기·훅은 표준 라이브러리를 사용한다. 자식 훅 환경의 PATH에서도 보여야 한다. |
| Codex CLI | 아래 옵션·훅 호환성 점검을 통과하는 버전 | `codex exec`, JSON 출력, stdin 프롬프트와 프로젝트 훅을 사용한다. |
| .NET | 루트 `global.json`을 만족하는 .NET 8 SDK | runtime만 설치해서는 restore/build/test를 실행할 수 없다. |
| 테스트 | 기본 xUnit v2 + VSTest, NuGet 접근 | 각 명시 test project의 실제 실행과 TRX 결과가 필요하다. |
| 선택 도구 | IDE, Node.js/npm, pytest/uv | IDE는 선택이다. npm은 해당 Codex 설치 방식에, pytest/uv는 Harness 테스트에 사용한다. |
제품 아키텍처나 CPU 대상을 일괄 지정하지 않는다. SDK와 제품 TFM의 역할은
[Microsoft global.json 문서](https://learn.microsoft.com/en-us/dotnet/core/tools/global-json)를 참고한다.
## 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/)에서
Python 3.10 이상을 설치한다. 실행기와 자식 훅이 실제 설치 경로를 찾도록 설정한다.
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를 열거나 WindowsApps 별칭으로 실행에 실패하면 앱 실행
별칭과 PATH 순서를 확인한다. `py`나 `uv run python`만 동작하는 상태는 충분하지
않다. 현재 Windows 훅은 `python`을 호출한다. 가상환경을 쓰면 활성화한 동일 터미널에서
Harness와 Codex를 실행한다. uv로 Python 테스트가 통과해도 훅 PATH 점검을 대신하지 않는다.
### .NET 8 SDK
[Microsoft .NET 8 다운로드](https://dotnet.microsoft.com/en-us/download/dotnet/8.0)에서
환경에 맞는 **SDK**를 설치하고 새 터미널을 연다. 루트의 `global.json` 기준은 다음과 같다.
```json
{
"sdk": {
"version": "8.0.424",
"rollForward": "latestPatch",
"allowPrerelease": false
}
}
```
이 SDK 번호는 초기 검증 기준이며 최신 버전이라는 뜻이 아니다. `latestPatch`는 같은
feature band의 적합한 패치를 선택하며 설치된 SDK 9로 넘어가는 정책이 아니다.
설치 목록과 실제 선택 결과를 구분해 **저장소 루트에서** 확인한다.
```powershell
Get-Command dotnet | Select-Object Name, Source
dotnet --list-sdks
dotnet --version
```
선택 결과는 위 정책을 만족하는 `8.0.4xx`여야 한다. SDK가 없거나 조건에 맞지 않으면
설치를 복구하거나 검토 후 SDK 기준을 변경한다. Harness는 SDK를 자동 설치하지 않는다.
`global.json`은 도구 선택이고 `.csproj`의 `TargetFramework`는 제품 대상이다.
신규 프로젝트는 `net8.0`, `Nullable=enable`, `ImplicitUsings=enable`을 기본으로 하고
언어 버전은 SDK 기본값을 사용한다.
## 3. Codex CLI 설치와 호환성 확인
[공식 Codex CLI 설치 안내](https://learn.chatgpt.com/docs/codex/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> -
```
저장소는 최소 Codex 버전을 선언하지 않으므로 `exec --help`로 해당 옵션을 확인한다.
2026-09-08 로컬 CLI `0.153.4`에서 옵션을 확인했지만 이것은 전체 모델 실행이나
자동 훅 차단 검증을 뜻하지 않는다.
실행기는 셸 alias나 PowerShell 함수 대신 Python 자식 프로세스로 CLI를 호출한다.
아래 점검에는 모델 호출이 없다.
```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를 확인한다. alias나 npm shim이 자식 프로세스에서 실행되지
않으면 공식 Windows 설치 방식과 실제 실행 파일 경로를 확인한 뒤 다시 점검한다.
## 4. 저장소 준비와 훅 확인
Git으로 clone하거나 기존 제품 저장소에 다음 구성요소를 가져온다. 기존 `AGENTS.md`와
훅 설정이 있으면 제품 규칙을 유지하면서 병합한다.
```text
AGENTS.md
global.json
.gitignore
.agents/skills/harness/SKILL.md
.agents/skills/review/SKILL.md
.codex/hooks.json
.harness/config.example.json
scripts/execute.py
scripts/hooks/
scripts/dotnet_harness/
docs/HARNESS.md
docs/HARNESS_WORKFLOW.md
docs/ENVIRONMENT_SETUP.md
docs/phase/README.md
```
실행기와 훅이 경로를 기준으로 저장소를 찾으므로 배치를 유지한다. 제품 권장 배치는
`src/<ProjectName>/`와 `tests/<ProjectName>.Tests/`이며 제품을 미리 만들어 놓았다는
뜻은 아니다. Harness 자체를 개발하려면 `tests/harness/`, `tests/fixtures/`와
`pytest.ini`도 유지한다.
```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을
수행하므로 초기 HEAD가 없는 상태에서 실제 phase 실행을 시작하지 않는다.
```powershell
git config --local user.name 'YOUR_NAME'
git config --local user.email 'YOUR_EMAIL'
```
저장소 루트에서 `codex`를 실행하고 `/hooks`로 `.codex/hooks.json`의 `PreToolUse`와
`Stop`이 로드되는지 확인한다. 프로젝트와 변경된 훅의 신뢰, 사용자·관리자 설정을
확인한다. `commandWindows`는 Windows용 명령 재정의이며 현재 실행기의
`--dangerously-bypass-hook-trust`는 검토된 자동화에서 활성 훅의 지속 신뢰 요구를
건너뛰는 옵션이다. 비활성 훅을 켜거나 빌드 설정을 대신하지 않는다.
[공식 Codex 훅 문서](https://learn.chatgpt.com/docs/hooks)
Windows 훅은 `python -X utf8`과 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`, 거부 JSON 없음, 입력 해석 오류 없음이다.
PreToolUse 거부는 stdout의 `hookSpecificOutput.permissionDecision: "deny"`와
`permissionDecisionReason`으로 판정한다. Windows PowerShell이 자식 프로세스의 종료
코드 `2`를 `1`로 바꿀 수 있어 훅은 JSON 응답과 종료 코드 `0`을 사용한다.
C# `Foo.cs` 편집에는 기본 루트 `tests/` 또는 편집 파일 옆 `tests/`, `test/`의
`FooTests.cs`나 `FooTest.cs`가 필요하다. 실제 실패 테스트 확인은 별도다. `Program.cs`는 자동 면제가
아니며 필요한 경우 정확한 `tdd.exclude` 경로와 이유를 기록한다.
실제 자동 훅 확인은 격리 저장소에서 테스트 없는 C# 편집 요청이 차단되는지,
대응 테스트 작성 후 허용되는지, 잘못된 빌드가 Stop에서 거부되는지를 관측한다.
등록 확인·직접 스크립트 실행·실제 CLI 이벤트 검증 결과를 구분해서 기록한다.
## 5. 제품 빌드 설정
`.harness/config.example.json`을 참고해 실제 제품에 맞는 version 2 설정을 작성한다.
JSON은 **UTF-8 BOM 없이**, 주석 없이 저장한다. PowerShell 7에서는
`Set-Content -Encoding utf8NoBOM`을 사용할 수 있다. 아래 `Example.sln`과 경로는
설명용이며 그대로 복사해 완성된 제품 설정으로 사용하지 않는다.
```json
{
"version": 2,
"projectType": "dotnet",
"dotnet": {
"target": "Example.sln",
"configuration": "Debug",
"testProjects": ["tests/Example.Tests/Example.Tests.csproj"]
},
"tdd": {
"testRoots": ["tests"],
"testPatterns": ["{stem}Tests.cs", "{stem}Test.cs"],
"exclude": []
}
}
```
target은 저장소 내부 `.sln` 또는 SDK 스타일 `.csproj`다. `projectType`는
`auto`(기본값) 또는 `dotnet`이다. target 생략 시 루트 단일 solution, 다음으로
제외 경로 밖 단일 project를 선택한다. 여러 후보면 target을 지정한다.
`.slnx`, 기존 .NET Framework project와 다른 언어 project는 지원하지 않는다.
제품이 있으면 `testProjects`에 **전체 제품 테스트**를 비어 있지 않은 목록으로 명시한다.
테스트가 solution에 포함되어 있어도 이 목록이 필요하다. 모든 설정 경로는 저장소
상대 경로이며 외부로 향하는 링크·junction, 없는 target과 알 수 없는 필드는 거부한다.
version 1은 새 설정으로 다시 작성한다. `exclude: []`는 유효하지만 명시된 test root,
test pattern과 제품의 test project 목록은 비워 둘 수 없다.
신규 테스트 기본값은 xUnit v2 + VSTest다. 테스트 project는 제품 project를 참조하고
`xunit`, `xunit.runner.visualstudio`, `Microsoft.NET.Test.Sdk`의 호환되는 안정 버전을
명시적으로 고정한다. fixture의 실제 버전은
[Smoke.Tests.csproj](../tests/fixtures/dotnet/Smoke/tests/Smoke.Tests/Smoke.Tests.csproj)를 참고한다.
fixture의 `NuGet.Config`는 도구 검증용 feed를 한정한다. 실제 제품에서는 제품의 feed와
인증 설정을 유지하고 restore 오류를 개별적으로 해결한다.
제품에서 테스트 project를 참조하지 않는다. 다른 기존 프레임워크도 VSTest/TRX 계약을
만족해야 한다. [Microsoft의 xUnit 구성 예제](https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-csharp-with-xunit)
아래는 설정 확인 명령이다. JSON 구문 검사와 설정 load는 제품 빌드가 아니다.
```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 dotnet_harness.config import load_config; print(load_config(Path.cwd()))"
```
제품 경로를 실제 값으로 바꾸고 각 명령의 종료 코드가 0인지 확인한다. 실패한 단계가
있으면 다음 단계로 진행하지 않는다. SDK 확인 다음에는 target의 실제 각 프로젝트와
모든 test project와 SDK로 평가한 ProjectReference의 전체 참조 그래프에서 TFM·저장소 경계를 확인한다. 아래 `src/Example/Example.csproj`도 예시이며
solution에 포함된 실제 프로젝트마다 반복한다. 모든 test project에 restore/build/test를 반복한다.
```powershell
dotnet --version
dotnet msbuild src/Example/Example.csproj -getProperty:TargetFramework,TargetFrameworks -getItem:ProjectReference -p:Configuration=Debug
dotnet msbuild tests/Example.Tests/Example.Tests.csproj -getProperty:TargetFramework,TargetFrameworks -getItem:ProjectReference -p:Configuration=Debug
dotnet restore Example.sln -p:Configuration=Debug
dotnet restore tests/Example.Tests/Example.Tests.csproj -p:Configuration=Debug
dotnet build Example.sln -c Debug --no-restore -warnaserror
dotnet build tests/Example.Tests/Example.Tests.csproj -c Debug --no-restore -warnaserror
$manualResults = Join-Path '.harness/test-results' ([guid]::NewGuid().ToString('N'))
dotnet test tests/Example.Tests/Example.Tests.csproj -c Debug --no-build --no-restore --logger trx --results-directory $manualResults
```
TFM 평가와 restore의 `Configuration`, build/test의 `-c`는 설정의 같은 값을 사용한다.
평가 결과의 `TargetFrameworks` 또는 `TargetFramework`는 모두 .NET 8이어야 한다.
`net8.0`과 .NET 8의 플랫폼별 TFM을 허용하며 빈 값·비지원 TFM은 restore 전에 거부한다.
상속·조건부 참조도 따라가며 저장소 밖 프로젝트는 거부한다. 참조별 AdditionalProperties나
SetConfiguration 등 별도 속성 전달은 현재 지원하지 않는다. 상세 계약은 [운영 가이드](HARNESS.md)를 따른다.
이 검사는 상속된 `Directory.Build.props`와 속성 참조를 평가하므로 `.csproj`의 문자만
확인한 결과로 대체하지 않는다.
Harness도 target과 test project별 restore/build를 수행하므로 solution에서 빠진 테스트를
누락하지 않는다. 현재 호출의 고유 결과 경로에서 **각 project의 모든 TRX**를 확인하며
실행된 테스트가 있어야 한다. exit code 0만으로는 충분하지 않다. 실패·손상·누락,
0건과 skip-only는 실패이며 과거 TRX를 재사용하지 않는다.
[VSTest dotnet test 문서](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-test-vstest)
`.gitignore`의 중첩 `bin/`, `obj/`, `.vs/`, `TestResults/`와 Harness 결과 제외를
확인한다. 공유할 `.harness/config.json`과 로컬 파일·산출물 정책을 자동 staging 전에
정한다. `tdd.exclude`는 테스트 요구 면제만 제어하고 제품 존재 탐색에는 적용되지 않는다.
`tests/fixtures/`는 도구 fixture로 예약하므로 실제 제품을 그 안에 두지 않는다.
## 6. 구축 완료 확인과 최초 실행
제품 metadata와 설정을 준비한 뒤 일반 터미널에서 Stop 훅을 직접 실행한다.
SDK 선택과 실제 TFM 평가에 이어 restore·build·test와 산출물 작성이 발생한다.
```powershell
python -X utf8 scripts/hooks/stop_validation.py
```
스크립트는 **실패해도 프로세스 종료 코드가 0일 수 있다**. 표준 출력의
`"continue": false`와 `stopReason`은 검증 실패다. 제품과 metadata가 없는 템플릿의
`제품 검증 대상 없음`이나 docs-only 생략을 제품 build 성공으로 기록하지 않는다.
`CODEX_STOP_VALIDATION_ACTIVE=1`인 재진입 환경에서도 생략될 수 있으므로 일반
터미널의 새 검증 결과와 실제 테스트 실행 건수를 확인한다.
실제 CLI에서도 Stop 실패가 종료 코드 `0`과 모델의 `completed` 상태를 남길 수 있다.
따라서 executor는 완료 보고 후 제품 검증을 직접 다시 실행하고, 성공해야만 step을
승인한다. 문서 전용 변경 판정도 이 단계에서 다시 확인하며 실패는 기존 재시도 예산을 사용한다.
[실행 흐름](HARNESS_WORKFLOW.md)과
[Harness 스킬](../.agents/skills/harness/SKILL.md)에 맞춰 실제 작업의
`phases/index.json`, `phases/<phase>/index.json`, `stepN.md`를 준비한다.
`context_files`에는 존재하는 파일만 넣는다. [phase 기록 형식](phase/README.md)에 따라
handoff는 약 60줄, step 기록에는 변경·결정·실제 검증·유효한 실패 접근을 남긴다.
실행 상태의 기준은 항상 phase index다.
`<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를 직접 실행하고 필요한 옵션·인증을 사용할 수 있다.
- 실제 CLI 이벤트에서 편집 훅과 Stop 검증이 동작한다.
- 실제 제품 또는 격리 fixture에서 .NET SDK 8 선택, 실제 TFM 평가, restore/build와 테스트가 성공하고
경고·차단 응답이 없으며 현재 TRX에서 실행 건수가 확인된다.
- 실제 phase의 dry-run이 파일·Git·상태를 변경하지 않고, 첫 step 실행 결과와 index가 일치한다.
Harness 자체 검증은 제품 검증과 별도로 실행한다.
```powershell
uv run --with pytest python -m pytest tests/harness -v -rs
uv run --with pytest python -m compileall -q scripts tests/harness
```
SDK/NuGet/CLI 문제로 실제 통합 검증이 skip되면 미검증으로 기록한다. subprocess 대역
테스트 통과와 실제 .NET·CLI 통과, 설치된 도구 확인과 새 환경의 설치 검증을 구분한다.
## 7. 자주 발생하는 문제
| 증상 | 확인 및 조치 |
|---|---|
| `python` 또는 `codex`를 찾지 못함 | 새 터미널의 PATH와 앱 실행 별칭을 확인한다. Python 자식 프로세스 점검까지 통과해야 한다. |
| `unexpected argument` / `unrecognized option` | `codex exec --help`로 실행기의 옵션 지원을 확인한다. |
| 인증 실패 | Harness를 실행하는 동일 Windows 계정과 Codex 환경에서 로그인 상태를 확인한다. |
| 훅이 로드되지만 차단되지 않음 | `/hooks`, 프로젝트·훅 신뢰, 사용자·관리자 설정, `commandWindows`, 자식 PATH와 실제 이벤트·matcher를 확인한다. |
| `dotnet` 누락 / SDK 없음 | runtime만 설치했는지 확인하고 SDK 설치와 PATH를 복구한다. |
| SDK 선택 오류 | 루트 `global.json`, `dotnet --list-sdks`와 루트 `dotnet --version`을 비교한다. 임의 디렉터리의 SDK 선택으로 대체하지 않는다. |
| 실제 TFM 평가 실패 | 같은 Configuration으로 각 `.csproj`의 `TargetFramework`/`TargetFrameworks`를 조회한다. 상속된 props·속성 참조·조건부 값을 확인하고 빈 값이나 .NET 8 이외의 대상을 수정한다. |
| NuGet restore 실패 | 오류 단계의 feed URL·네트워크·인증·패키지 버전을 확인한다. 성공한 테스트로 기록하지 않는다. |
| 여러 target 후보 | `dotnet.target`에 실제 `.sln` 또는 SDK 스타일 `.csproj`를 명시한다. |
| C# orphan / metadata 없음 | C# 소스에 맞는 지원 metadata와 target을 준비한다. exclude로 제품을 숨기지 않는다. |
| 테스트 0건 / skip-only / TRX 누락 | `testProjects`, 테스트 SDK·어댑터, 테스트 발견과 실행 조건을 확인한다. 현재 project별 결과를 확인한다. |
| build 경고로 실패 | nullable·분석기 등 실제 진단을 수정한다. 경고를 숨겨 통과시키지 않는다. |
| `TDD GUARD` 차단 | 대응 테스트를 먼저 추가하고 red 단계 확인 후 구현한다. `Program.cs`도 기본 검사 대상이다. |
| `dubious ownership` | 현재 계정 소유 checkout을 우선한다. 신뢰하는 공유 경로라면 해당 경로만 Git `safe.directory`에 등록한다. |
| 소스 탐색 중 `PermissionError` / 접근 거부 | Windows sandbox에서 생성한 폴더의 ACL과 훅 실행 계정을 확인한다. 일반 실행 계정이 소스·테스트 폴더를 읽을 수 있어야 한다. 필요하면 해당 계정으로 프로젝트 폴더를 먼저 준비한다. 읽기 실패는 대상 없는 템플릿으로 처리하지 않는다. |
| phase 또는 context 파일 누락 | 실제 phase/index/step과 존재하는 참조 파일을 준비한다. 인계 문서를 상태 파일로 사용하지 않는다. |
| build·Codex 시간 초과 | Stop 전체와 각 Codex 호출 제한은 1,800초다. 로그의 실패 단계·argv·cwd를 확인한다. |
재시도, `error`/`blocked` 복구는 [Harness 운영 가이드](HARNESS.md)와
[실행 흐름](HARNESS_WORKFLOW.md)을 따른다.
@@ -0,0 +1,165 @@
# Harness 운영 가이드
Harness는 Windows에서 C#·.NET 8 제품 개발의 step 실행, 편집 정책과 build/test를
지원하는 Python 도구다. 제품 목표와 기능은 [PRD](PRD.md)에 별도로 둔다.
상세 실행 흐름은 [HARNESS_WORKFLOW.md](HARNESS_WORKFLOW.md)를 참고한다.
## 요구 환경과 설정
설치, PATH·인증·훅 점검과 첫 검증은 [환경 구축 가이드](ENVIRONMENT_SETUP.md)를 따른다.
- Windows, Git, 실제 `python` 명령으로 실행 가능한 Python 3.10 이상, 호환 Codex CLI
- 루트 `global.json`을 만족하는 .NET 8 **SDK**와 NuGet restore 접근
- 신규 테스트 기본값 xUnit v2 + VSTest; IDE는 선택 사항
- Harness 자체 테스트에는 pytest/uv 사용
`global.json`의 기준은 SDK `8.0.424`, `latestPatch`, `allowPrerelease=false`다.
SDK 선택과 `.csproj`의 제품 대상 TFM은 별개이며 신규 프로젝트 기본 TFM은 `net8.0`이다.
다음 경로는 실제 제품이 아닌 설정 예시다. `.harness/config.example.json`을 참고해
실제 제품에 맞는 `.harness/config.json`을 작성한다. 제품이 없는 템플릿에는 가상의
target을 넣은 설정 파일을 만들지 않는다.
```json
{
"version": 2,
"projectType": "dotnet",
"dotnet": {
"target": "Example.sln",
"configuration": "Debug",
"testProjects": ["tests/Example.Tests/Example.Tests.csproj"]
},
"tdd": {
"testRoots": ["tests"],
"testPatterns": ["{stem}Tests.cs", "{stem}Test.cs"],
"exclude": []
}
}
```
`projectType`는 `auto`(기본값) 또는 `dotnet`이다. `dotnet.target`은 선택 사항이며,
명시한 저장소 내부 `.sln` 또는 SDK 스타일 `.csproj`를 우선한다. 생략하면 루트의
단일 solution, 다음으로 제외 경로 밖의 단일 C# project를 찾는다. 후보가 여러 개면
target을 명시한다. C# 소스가 있지만 지원 metadata가 없으면 오류다.
제품이 있으면 `dotnet.testProjects`는 전체 제품 테스트를 담은 비어 있지 않은 명시
목록이어야 한다. `configuration`의 기본값은 `Debug`다. 모든 설정 경로는 저장소
상대 경로이며 외부 symlink/junction 대상, 없는 target, 알 수 없는 필드와 version 1은
거부한다. `tdd.testRoots`와 `testPatterns`를 명시하면 빈 배열을 허용하지 않는다.
`tdd.exclude: []`는 유효하다. 테스트 패턴은 `{stem}`을 포함하는 `.cs` 파일명이며
경로 구분자를 넣지 않는다. SDK·TFM·패키지 버전을 Harness 설정에 중복하지 않는다.
## Phase 실행
```powershell
python -X utf8 scripts/execute.py <phase-name> --dry-run
python -X utf8 scripts/execute.py <phase-name>
python -X utf8 scripts/execute.py <phase-name> --push
```
먼저 `--dry-run`으로 첫 pending step이 읽을 파일과 프롬프트 문자 수를 확인한다. 이
모드는 Codex 호출, branch 전환, timestamp, 파일 쓰기, commit과 push를 하지 않는다.
`--push`와 함께 사용할 수 없다. 실제 실행은 자동 staging·commit을 수행하므로
검토한 기준 commit이 있는 전용 checkout/worktree에서 시작한다.
## 선택 문맥과 호환성
step index의 `context_files`에 필요한 기존 문서를 저장소 상대 경로로 적는다.
```json
{
"step": 1,
"name": "implement-parser",
"status": "pending",
"context_files": ["docs/ARCHITECTURE.md"]
}
```
필드를 생략하거나 빈 배열로 두면 `AGENTS.md`, 자동 인계와 step 이외의 문서는
추가되지 않는다. glob, URL, 절대 경로와 저장소 밖 경로는 지원하지 않는다.
이전에 모든 문서를 자동 주입하던 동작에 의존하는 phase는 필요한 문서를 명시한다.
현재 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"}
```
`validation` 생략 또는 `full`은 전체 제품 검증을 수행한다. `docs-only`도 실제 Git
변경이 루트 `AGENTS.md`, `docs/**/*.md`, 현재 phase의 `index.json`과
`stepN-output.json`에만 있고 실제 문서 변경이 있을 때만 제품 검증을 생략한다.
Git 조회 실패, 빈 변경, bookkeeping만 있는 변경, 다른 코드·설정·스킬 파일이 섞인
변경은 전체 검증으로 돌아간다. rename은 이전·새 경로를 모두 확인하며 symlink나
junction 경로는 문서 전용으로 인정하지 않는다. 제품 build가 Markdown을 입력으로
사용하면 `full`을 선택한다.
## .NET 검증
Stop 훅은 `scripts/dotnet_harness/`를 통해 저장소 루트에서 다음 순서로 검증한다.
1. SDK 선택 확인.
2. target·test project와 평가된 `ProjectReference`로 연결되는 모든 프로젝트의 저장소 내부 경로·C# SDK 형식·실제 .NET 8 TFM 확인.
3. target과 모든 test project의 중복 경로를 제거한 뒤 같은 Configuration으로 각각 restore.
4. 각 대상을 `--no-restore -warnaserror`로 build.
5. 각 test project를 `--no-build --no-restore --logger trx`로 실행.
6. 현재 호출의 project별 고유 결과 디렉터리에 생성된 모든 TRX 검사.
TFM과 참조 확인은 각 `.csproj`에 `dotnet msbuild -getProperty:TargetFramework,TargetFrameworks
-getItem:ProjectReference -p:Configuration=<configuration>`을 실행한다. 다중 TFM은
각 `TargetFramework`의 조건부 참조도 평가한다. 프로젝트 XML의 문자만 확인하지 않고
상속된 props와 속성 참조를 평가한 결과를 검사한다. 값이 없거나 .NET 8 이외의 TFM이
포함되면 restore 전에 실패한다. `net8.0`과 .NET 8의 플랫폼별 TFM을 허용한다.
참조별 `AdditionalProperties`, `GlobalPropertiesToRemove`, `SetConfiguration`,
`SetPlatform`, `SetTargetFramework`로 평가 문맥을 바꾸는 구성은 현재 지원하지 않으며
명확한 오류로 거부한다. 프로젝트는 공유 Configuration과 각 프로젝트의 TFM을 사용한다.
solution에 빠진 test project도 직접 restore/build한다. 한 단계라도 실패하면 이후
단계를 멈춘다. 전체 deadline은 1,800초이며 명령은 shell 확장 없이 argv 배열로
실행한다. NuGet 오류는 restore 실패로 보고한다.
실패·손상·누락된 TRX, 실행된 테스트 0건, skip-only 결과는 검증 실패다. 여러 결과
중 하나라도 실패하면 성공 결과로 덮지 않는다. 오래된 TRX는 현재 실행의 근거가
아니다. 기본 xUnit 이외의 기존 테스트도 VSTest/TRX 계약을 만족해야 한다.
C# 제품과 지원 metadata가 모두 없는 템플릿은 `제품 검증 대상 없음`으로 구분한다.
`tests/fixtures/`는 도구 검증용이므로 제품 자동 탐색에서 제외한다. 대상 없음과
docs-only 생략을 제품 빌드 성공으로 기록하지 않는다.
## TDD와 복구
`Foo.cs` 편집에는 기본 test root인 저장소 `tests/`와 편집 파일 옆 `tests/`, `test/`에서
`FooTests.cs` 또는 `FooTest.cs`가 먼저 있어야 한다. 설정으로 테스트 루트·패턴을
조정할 수 있다. 테스트 루트의 보조 코드, 중첩된 `bin/`, `obj/`, `.git/`, `.vs/`,
`.harness/`, `TestResults/`, 외부 의존성·명시 generated 경로는 정책에서 제외한다.
`Program.cs`, DTO, interface, `.Designer.cs`는 파일명만으로 면제하지 않는다.
필요하면 예를 들어 `src/Example/Program.cs`처럼 정확한 `tdd.exclude` 경로와
composition root 등 실제 이유를 기록한다. 이 설정은 편집 전 테스트 요구만 면제하며
제품 존재 탐색을 숨기지 않는다. 수동 작성 partial 파일은 각 stem에 대응 테스트가
필요하다. 파일 존재 검사는 테스트 품질이나 실제 red-green 실행을 대신하지 않는다.
Executor는 Codex를 최초 호출 포함 최대 3회 호출한다. 재시도 입력은 marker를 포함해
4,000자로 제한하며 앞 1,500자와 끝부분 최대 2,500자를 보존한다. 원본
`stepN-output.json`은 온전히 저장되지만 다음 시도에서 덮어쓴다. exit code 0과
`completed` 상태와 executor의 독립 제품 검증 성공이 모두 있어야 성공한다.
Stop 실패도 CLI exit 0으로 끝날 수 있어 executor가 완료 보고 후 검증을 다시 실행한다.
독립 검증은 별도의 1,800초 제한을 공유하며 실패하면 같은 호출 예산 안에서 재시도한다.
`docs-only` 판정도 실제 변경을 다시 확인한다. `blocked`는 즉시 멈춘다.
`error` 또는 `blocked`를 재개할 때는 원인을 해결하고 step을 `pending`으로 바꾼 뒤
오류 이유와 해당 timestamp를 제거한다. 인증이나 CLI 환경 오류는 즉시 중단한다.
Harness 자체 Python 검증:
```powershell
uv run --with pytest python -m pytest tests/harness -v -rs
uv run --with pytest python -m compileall -q scripts tests/harness
```
실제 SDK 통합 테스트의 skip과 subprocess 대역 테스트 성공을 구분한다. 검증한
OS·SDK·패키지 버전, 실행 건수, 경고와 남은 미검증 항목은 phase 기록에 남긴다.
@@ -0,0 +1,171 @@
# Harness 실행 흐름
Harness는 승인된 phase를 독립 Codex 세션으로 실행하고 C#·.NET 8 제품 규칙을
검증한다. 설정 예시는 [HARNESS.md](HARNESS.md), phase 기록 형식은
[phase README](phase/README.md)를 참고한다.
## 1. 계획
`AGENTS.md`와 변경에 관련된 제품 문서·코드·테스트를 확인하고 작업을 한 가지 책임의
step으로 나눈다. 각 step은 목표와 범위, 관련 파일, 핵심 제약, 실제 Acceptance
Criteria를 가진다. 공개 계약이 필요한 경우에만 시그니처를 적는다. 이전 대화에
의존하지 않으며 문서 전체를 step 본문에 복사하지 않는다.
제품 기능의 red-green 작업은 한 step 안에서 끝낸다.
```text
실패 테스트 작성·확인 → 최소 구현 → 전체 관련 테스트 성공 → step 종료
```
## 2. Phase 파일
```text
phases/
index.json
<phase>/
index.json
step0.md
docs/phase/<phase>/
handoff.md
step0.md
```
`phases/{phase}/index.json`이 실행 상태의 기준이다. `context_files`는 기존 참고 문서,
`validation`은 `full` 또는 `docs-only`를 선택한다.
```json
{
"project": "<제품명 또는 미정>",
"phase": "add-parser",
"steps": [
{
"step": 0,
"name": "implement-parser",
"status": "pending",
"context_files": ["docs/ARCHITECTURE.md"],
"validation": "full"
}
]
}
```
## 3. 읽기 전용 확인
```powershell
python scripts/execute.py <phase> --dry-run
```
첫 pending step의 현재 주입 경로, 경로별 문자 수와 전체 프롬프트 문자 수를
출력한다. Codex, Git, timestamp와 파일을 변경하지 않는다. `--push`와 동시 사용은
오류다.
## 4. 프롬프트 구성
각 호출 직전에 index, `context_files`와 handoff를 다시 읽고 다음 순서로 한 번만
결합한다.
```text
AGENTS.md
→ 공통 작업 규칙
→ context_files
→ 현재 phase handoff 또는 호환 summary
→ 직전 실패 요약
→ 현재 step
```
`context_files` 생략은 추가 문서 없음이다. 잘못된 배열, 없는 파일, UTF-8로 읽을 수
없는 파일, 절대 경로, 저장소 밖 경로와 외부를 가리키는 symlink/junction은 Codex나 Git 변경 전에
거부한다. 경로 표기와 대소문자만 다른 중복도 한 번만 넣는다.
handoff가 없을 때만 현재 step보다 앞선 완료 step 중 가장 최근 하나의 summary를
최대 1,000자로 전달한다. 이후 step이나 모든 과거 summary를 누적하지 않는다.
## 5. 실행과 상태
Executor는 `feat-{phase}` branch에서 각 pending step을 독립 `codex exec` 프로세스로
실행한다. Codex는 종료 전에 현재 `docs/phase/{phase}/stepN.md`, handoff와 phase index를
갱신한다.
| 상태 | 의미 |
|---|---|
| `completed` | exit code 0과 executor 독립 검증 통과 후 timestamp와 commit 처리 |
| `error` | 자동 시도 예산 소진, 이유와 timestamp 기록 후 중단 |
| `blocked` | 사용자 또는 외부 환경 조치 필요, 추가 호출 없이 중단 |
프로세스가 nonzero로 끝나면 index에 `completed`가 있어도 성공으로 처리하지 않는다.
Stop 실패가 exit code 0으로 끝나는 경우도 있으므로 완료 보고 직후 executor가 제품을
다시 검증한다. 실패하면 성공 상태를 취소하고 같은 최대 3회 호출 예산에서 재시도한다.
독립 검증에도 실제 docs-only 판정과 별도의 총 1,800초 검증 제한을 적용한다.
## 6. 재시도와 로그
Executor의 호출 예산은 최초 호출을 포함해 총 3회다. 한 Codex 세션 안의 일반적인
red-green 수정 횟수를 별도로 3회로 제한하지 않는다.
재시도 입력의 오류 본문은 총 4,000자로 제한한다. 앞 1,500자와 끝부분 최대 2,500자를
남기고 중간 생략 marker를 제한 안에 포함한다. 전체 stdout/stderr 결과는
`phases/{phase}/stepN-output.json`에 저장하지만 다음 시도에서 같은 파일을 덮어쓴다.
장기 보관할 시행착오는 step 기록에 요약한다. timeout도 같은 호출 예산의 실패이며
인증·CLI 옵션 같은 환경 오류는 즉시 중단한다.
## 7. 편집 전 정책
PreToolUse 훅은 위험한 삭제, 강제 push, hard reset과 SQL 파괴 명령을 차단한다.
Windows 셸의 종료 코드 변환에 의존하지 않도록 JSON `permissionDecision: "deny"`로
거부한다. 실제 Codex 편집 payload의 `tool_input.command`에서 patch 경로를 읽는다.
C# 제품 `.cs` 편집에는 대응 테스트 파일 존재 여부를 검사한다. 기본 루트는
저장소 `tests/`이며 편집 파일 옆 `tests/`, `test/`도 대응 테스트를 찾는다.
`Foo.cs`의 대응 이름은 `FooTests.cs` 또는 `FooTest.cs`다.
테스트 루트의 보조 코드, 중첩 `bin/`·`obj/` 등 산출물, 외부 의존성·명시 generated
경로는 제외한다. `Program.cs`나 `.Designer.cs`라는 이름만으로 면제하지 않는다.
필요한 면제는 정확한 `tdd.exclude` 경로와 이유를 기록한다. 이 설정은 제품 존재
탐색을 우회하지 않는다. 테스트 파일 존재와 실제 red-green 증거를 구분한다.
`tdd.testPatterns`는 `{stem}`을 포함하는 C# 파일명 패턴이다. 경로 구분자는 허용하지
않으며 테스트 위치는 `testRoots`로 지정한다. 한 검사 호출 안에서 후보 목록을 재사용하고
다음 호출에는 파일 추가·삭제를 반영한다. 제품 탐색에서 제외한 산출물·fixture 경로의
파일을 대응 테스트 증거로 사용하지 않는다.
## 8. 종료 전 검증
`validation` 생략 또는 `full`이면 Stop 훅이 발견한 C# 제품 전체를 검증한다.
- 저장소 루트 `global.json`에 따른 .NET 8 SDK 선택 확인
- target의 실제 각 프로젝트와 모든 test project의 `TargetFramework`/`TargetFrameworks`를
SDK로 평가해 .NET 8인지 확인; 상속된 props·속성 및 ProjectReference 그래프도 평가하며 빈 값·비지원 TFM·저장소 밖 참조는 거부
- version 2 설정의 target과 명시된 모든 `testProjects`를 restore/build;
build 경고는 오류로 처리하고 solution에 빠진 test project도 직접 build
- 각 test project를 VSTest로 실행하고 현재 호출의 고유 디렉터리에 생성된 모든 TRX 검사
- 실행 0건·skip-only·누락·손상·실패 TRX는 실패; 과거 결과를 성공 근거로 사용하지 않음
- 지원 metadata 없음: 제외되지 않은 C# 소스가 있으면 orphan 오류
- 제품과 metadata 모두 없음: 제품 검증 대상 없음; 제품 build 성공으로 기록하지 않음
전체 검증은 1,800초 deadline을 공유하며 SDK·TFM 평가·restore·build·test 중 실패한 단계에서
중단한다. `tests/fixtures/`는 제품 자동 탐색에서 제외하는 도구 검증용이다.
Stop 프로세스 종료 코드만으로 통과를 판정하지 않는다. `continue: false`와
`stopReason` 응답은 검증 실패이며 실제 자동 훅 호출은 별도로 확인한다.
`docs-only`이면 executor가 현재 phase를 자식 프로세스 환경으로 전달하고 Stop 훅이
실제 Git 변경을 다시 검사한다. 루트 `AGENTS.md`, `docs/**/*.md`, 현재 phase의
`index.json`과 `stepN-output.json`만 허용하며 실제 문서 변경이 있어야 생략한다.
rename은 원래 경로와 새 경로를 모두 검사한다. Git 실패, 빈 변경, bookkeeping만 있는
변경, untracked C# 또는 다른 파일이 하나라도 있으면 전체 검증으로 돌아간다.
symlink나 junction으로 해석되는 변경 경로도 보수적으로 전체 검증한다.
## 9. Commit, 완료와 재개
step 성공 시 executor는 제품 변경과 metadata를 분리해 commit한다. 모든 step이
성공하면 phase와 top-level 상태를 `completed`로 바꾸고 `--push`가 있으면
`origin/feat-{phase}`에 push한다. 실행기는 광범위한 staging을 할 수 있으므로 깨끗한
전용 worktree에서 실행한다.
재개 절차:
1. `error` 또는 `blocked` 원인을 해결한다.
2. 해당 step status를 `pending`으로 바꾼다.
3. `error_message`/`failed_at` 또는 `blocked_reason`/`blocked_at`을 제거한다.
4. handoff, 직전 원본 출력과 index를 확인한 뒤 다시 실행한다.
강제 종료로 handoff가 낡았을 수 있으므로 index 상태를 우선한다. 이 저장소의 fixture
검증은 제품 요구사항이나 실제 제품 build 완료를 의미하지 않는다. 실행한 명령과
결과, SDK·테스트 건수와 남은 미검증 항목은 해당 phase 기록에서 확인한다.
@@ -0,0 +1,37 @@
# C# .NET 8 제품 PRD
Harness 운영 요구사항은 이 문서의 제품 요구사항에 포함하지 않는다.
## 목표
미정 — 제품 요구사항 정의 시 확정.
## 사용자
미정 — 제품 요구사항 정의 시 확정.
## 핵심 기능
미정 — 제품 요구사항 정의 시 확정.
## 제약
- C#·.NET 8 제품으로 개발하며 신규 프로젝트 기본 TFM은 `net8.0`이다.
- 제품의 UI, 배포 OS·방식, 성능·보안·호환성 조건은 미정이다.
Harness의 Windows 실행 제약으로 제품 배포 OS를 확정하지 않는다.
- 공개 API 계약과 프로젝트 참조 방향을 유지하며 제품이 테스트를 참조하지 않는다.
- 신규 프로젝트의 nullable 검사를 활성화하고 빌드 경고는 오류로 처리한다.
- 새 기능과 버그 수정은 실패하는 테스트를 먼저 확인한다.
- 신규 테스트 기본값은 xUnit v2 + VSTest다. 제품의 전체 테스트 범위는 요구사항과
함께 정하고 명시적인 `testProjects`에 반영한다.
## 완료 조건
미정 — 제품 기능별 실행 가능한 build/test 명령과 수용 조건을 정의할 때 확정.
## 제외 범위
- Harness 실행기, 훅, phase 관리와 문맥 경량화는 개발 도구의 책임이며 제품 기능이
아니다.
- 구체적인 제품 기능, API, UI, 계층과 배포 범위는 요구사항이 정해질 때까지 확정하지 않는다.
- .NET Framework 4.x와 Microsoft.Testing.Platform 전용 실행은 이번 템플릿 범위 밖이다.
@@ -0,0 +1,7 @@
{
"sdk": {
"version": "8.0.424",
"rollForward": "latestPatch",
"allowPrerelease": false
}
}
@@ -0,0 +1,2 @@
[pytest]
testpaths = tests/harness
@@ -0,0 +1 @@
"""Windows .NET 8 C# product validation support."""
@@ -0,0 +1 @@
""".NET validation command construction."""
@@ -0,0 +1,2 @@
class AdapterError(RuntimeError):
pass
@@ -0,0 +1,36 @@
from uuid import uuid4
from .base import AdapterError
from ..models import (CommandSpec, ProjectKind, ResultCheck, ResultCheckKind,
ValidationPlan, ValidationStep)
class DotnetAdapter:
def create_plan(self, root, selection, config, tools):
root = root.resolve()
if not config.dotnet.test_projects:
raise AdapterError("dotnet.testProjects must specify the product tests")
tests = tuple(dict.fromkeys(config.dotnet.test_projects))
targets = tuple(dict.fromkeys((selection.project_file, *tests)))
configuration = config.dotnet.configuration
steps = []
for action in ("restore", "build"):
for target in targets:
argv = (str(tools.dotnet), action, str(target))
# Restore and build must evaluate the same conditional references.
if action == "restore":
argv += (f"-p:Configuration={configuration}",)
else:
argv += ("-c", configuration, "--no-restore", "-warnaserror")
steps.append(ValidationStep(CommandSpec(argv, root, f"{action}: {target.name}")))
results = root / ".harness/test-results" / uuid4().hex
for index, target in enumerate(tests):
directory = results / str(index)
argv = (str(tools.dotnet), "test", str(target), "-c", configuration,
"--no-build", "--no-restore", "--logger", "trx",
"--results-directory", str(directory))
steps.append(ValidationStep(
CommandSpec(argv, root, f"test: {target.name}"),
(ResultCheck(ResultCheckKind.TRX_HAS_TESTS, directory),),
))
return ValidationPlan(ProjectKind.DOTNET, tuple(steps))
@@ -0,0 +1,81 @@
import json
from pathlib import Path, PureWindowsPath
from typing import Any
from .discovery import DiscoveryError, validate_csproj
from .models import DotnetConfig, HarnessConfig, TDDConfig
DEFAULT_TEST_PATTERNS = ("{stem}Tests.cs", "{stem}Test.cs")
class ConfigError(ValueError):
pass
def _mapping(value: Any, allowed: set[str], label: str) -> dict:
if not isinstance(value, dict):
raise ConfigError(f"{label} must be an object")
unknown = sorted(set(value) - allowed)
if unknown:
raise ConfigError(f"{label} contains unexpected field: {unknown[0]}; use version 2 .NET configuration")
return value
def _string(value: Any, label: str) -> str:
if not isinstance(value, str) or not value.strip():
raise ConfigError(f"{label} must be a non-empty string")
return value
def _array(value: Any, label: str, *, allow_empty=False) -> tuple[str, ...]:
if not isinstance(value, list) or (not value and not allow_empty):
raise ConfigError(f"{label} must be a {'non-empty ' if not allow_empty else ''}string array")
return tuple(_string(item, label) for item in value)
def _repo_path(root: Path, raw: str, label: str) -> Path:
raw = _string(raw, label)
windows = PureWindowsPath(raw)
if windows.drive or windows.root or Path(raw).is_absolute():
raise ConfigError(f"{label} must be repository-relative")
resolved = (root / raw.replace("\\", "/")).resolve()
if not resolved.is_relative_to(root):
raise ConfigError(f"{label} resolves outside the repository")
return resolved
def load_config(root: Path) -> HarnessConfig:
root = root.resolve()
path = root / ".harness/config.json"
try:
data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {"version": 2}
except (OSError, ValueError) as exc:
raise ConfigError(f"cannot read configuration {path}: {exc}") from exc
data = _mapping(data, {"version", "projectType", "dotnet", "tdd"}, "config")
if type(data.get("version")) is not int or data["version"] != 2:
raise ConfigError("version must be 2; rewrite legacy configuration using the .NET config example")
project_type = data.get("projectType", "auto")
if project_type not in ("auto", "dotnet"):
raise ConfigError("projectType must be auto or dotnet")
dotnet = _mapping(data.get("dotnet", {}), {"target", "configuration", "testProjects"}, "dotnet")
target = _repo_path(root, dotnet["target"], "dotnet.target") if "target" in dotnet else None
if target is not None and (target.suffix.lower() not in {".sln", ".csproj"} or not target.is_file()):
raise ConfigError("dotnet.target must be an existing .sln or SDK-style .csproj")
configuration = _string(dotnet.get("configuration", "Debug"), "dotnet.configuration")
tests = tuple(_repo_path(root, item, "dotnet.testProjects") for item in _array(dotnet["testProjects"], "dotnet.testProjects")) if "testProjects" in dotnet else ()
for test in tests:
try:
validate_csproj(test)
except DiscoveryError as exc:
raise ConfigError(f"dotnet.testProjects: {exc}") from exc
tdd = _mapping(data.get("tdd", {}), {"testRoots", "testPatterns", "exclude"}, "tdd")
roots = _array(tdd["testRoots"], "tdd.testRoots") if "testRoots" in tdd else ("tests",)
test_roots = tuple(_repo_path(root, item, "tdd.testRoots") for item in roots)
patterns = _array(tdd["testPatterns"], "tdd.testPatterns") if "testPatterns" in tdd else DEFAULT_TEST_PATTERNS
for pattern in patterns:
if "{stem}" not in pattern or not pattern.endswith(".cs") or any(char in pattern.replace("{stem}", "") for char in "{}/\\"):
raise ConfigError("tdd.testPatterns must be C# filenames containing only the {stem} placeholder")
exclude = _array(tdd["exclude"], "tdd.exclude", allow_empty=True) if "exclude" in tdd else ()
for item in exclude:
_repo_path(root, item, "tdd.exclude")
return HarnessConfig(2, project_type, DotnetConfig(target, configuration, tests), TDDConfig(test_roots, patterns, exclude))
@@ -0,0 +1,106 @@
import re
import xml.etree.ElementTree as ET
from pathlib import Path, PureWindowsPath
from .models import DiscoveryResult, HarnessConfig, ProjectKind, ProjectSelection
from .paths import iter_source_files
class DiscoveryError(RuntimeError):
pass
def validate_csproj(path: Path) -> None:
if path.suffix.lower() != ".csproj" or not path.is_file():
raise DiscoveryError(f"expected an existing SDK-style .csproj: {path}")
try:
project = ET.parse(path).getroot()
except (ET.ParseError, OSError) as exc:
raise DiscoveryError(f"cannot read project {path}: {exc}") from exc
name = lambda element: element.tag.rsplit("}", 1)[-1]
if name(project) != "Project" or not (
project.get("Sdk", "").strip()
or any(name(child) == "Sdk" and child.get("Name", "").strip() for child in project)
):
raise DiscoveryError(f"project must use SDK style; .NET Framework is unsupported: {path}")
for element in project.iter():
tag = name(element)
value = (element.text or "").strip()
if tag == "TargetFrameworkVersion" and value:
raise DiscoveryError(f".NET Framework metadata is unsupported: {path}")
if tag in {"TargetFramework", "TargetFrameworks"}:
# Build evaluates inherited properties and expressions. This preflight
# rejects explicit incompatible values rather than guessing expansion.
for target in value.split(";"):
target = target.strip()
if target and "$(" not in target and not re.fullmatch(r"net8\.0(?:-[A-Za-z0-9.]+)?", target):
raise DiscoveryError(f"only net8.0 targets are supported; found {target!r}: {path}")
def _validate_solution(path: Path, root: Path) -> tuple[Path, ...]:
try:
content = path.read_text(encoding="utf-8-sig")
except (OSError, UnicodeError) as exc:
raise DiscoveryError(f"cannot read solution {path}: {exc}") from exc
if "Microsoft Visual Studio Solution File" not in content:
raise DiscoveryError(f"invalid .sln file: {path}")
projects = []
folder_types = {"66A26720-8FB5-11D2-AA7E-00C04F688DDE", "2150E333-8FDC-42A3-9474-1A3956D46DE8"}
csharp_types = {"FAE04EC0-301F-11D3-BF4B-00C04F79EFBC", "9A19103F-16F7-4668-BE54-9A1E7A4F7556"}
for line in content.splitlines():
if not line.lstrip().startswith("Project("):
continue
match = re.match(r'\s*Project\("\{([^}]+)\}"\)\s*=\s*"[^"]*",\s*"([^"]+)"', line)
if not match:
raise DiscoveryError(f"invalid project entry in solution: {path}")
kind, reference = match.groups()
if kind.upper() in folder_types:
continue
if kind.upper() not in csharp_types:
raise DiscoveryError(f"unsupported project type in solution: {reference}")
windows_path = PureWindowsPath(reference)
if windows_path.drive or windows_path.root or Path(reference).is_absolute():
raise DiscoveryError(f"solution project must be repository-relative: {reference}")
candidate = (path.parent / reference.replace("\\", "/")).resolve()
if not candidate.is_relative_to(root):
raise DiscoveryError(f"solution project resolves outside repository: {reference}")
validate_csproj(candidate)
projects.append(candidate)
if not projects:
raise DiscoveryError(f"solution has no supported C# projects: {path}")
return tuple(projects)
def discover_project(root: Path, config: HarnessConfig) -> DiscoveryResult:
root = root.resolve()
target = config.dotnet.target
if target is None:
files = tuple(iter_source_files(root))
solutions = [path for path in files if path.parent == root and path.suffix.lower() == ".sln"]
candidates = solutions or [path for path in files if path.suffix.lower() == ".csproj"]
if len(candidates) > 1:
raise DiscoveryError("multiple project candidates; set dotnet.target")
if candidates:
target = candidates[0]
elif config.project_type == "dotnet" or any(
path.suffix.lower() in {".cs", ".sln", ".slnx", ".vcxproj", ".fsproj", ".vbproj"}
for path in files
):
raise DiscoveryError("no supported C# metadata found; set dotnet.target to a .sln or SDK-style .csproj")
else:
return DiscoveryResult(None)
target = target.resolve()
if not target.is_relative_to(root) or not target.is_file():
raise DiscoveryError(f"project target must be an existing repository file: {target}")
if target.suffix.lower() == ".sln":
projects = _validate_solution(target, root)
else:
validate_csproj(target)
projects = (target,)
if not config.dotnet.test_projects:
raise DiscoveryError("product validation requires an explicit non-empty dotnet.testProjects list")
for test_project in config.dotnet.test_projects:
if not test_project.resolve().is_relative_to(root):
raise DiscoveryError(f"test project resolves outside repository: {test_project}")
validate_csproj(test_project)
return DiscoveryResult(ProjectSelection(ProjectKind.DOTNET, target, projects))
@@ -0,0 +1,78 @@
"""Validate the SDK-evaluated project-reference graph before restore."""
import json
import subprocess
import time
from collections import deque
from pathlib import Path
from .discovery import DiscoveryError, validate_csproj
from .models import CommandSpec, ResultCheck, ResultCheckKind, ValidationPlan, ValidationStep
from .process import ValidationFailure, execute_plan
def _project_path(root, path):
try:
path = path.resolve()
if not path.is_relative_to(root):
raise ValidationFailure(f"ProjectReference resolves outside the repository: {path}")
validate_csproj(path)
except (DiscoveryError, OSError, ValueError) as exc:
raise ValidationFailure(f"invalid ProjectReference {path}: {exc}") from exc
return path
def validate_project_graph(
root, selection, config, tools, *, deadline, clock=time.monotonic, run=subprocess.run,
):
root = root.resolve()
pending = deque((path, None) for path in
(*selection.projects, *config.dotnet.test_projects))
visited = set()
while pending:
project, framework = pending.popleft()
project = _project_path(root, project)
key = (project, framework)
if key in visited:
continue
visited.add(key)
argv = (
str(tools.dotnet), "msbuild", str(project),
"-getProperty:TargetFramework,TargetFrameworks",
"-getItem:ProjectReference",
f"-p:Configuration={config.dotnet.configuration}",
)
if framework is not None:
argv += (f"-p:TargetFramework={framework}",)
plan = ValidationPlan(selection.kind, (ValidationStep(
CommandSpec(argv, root, f"metadata: {project.name}"),
(ResultCheck(ResultCheckKind.PROJECT_IS_NET8),),
),))
result = execute_plan(plan, root, deadline=deadline, clock=clock, run=run)[0]
try:
data = json.loads(result.stdout)
references = data["Items"]["ProjectReference"]
if not isinstance(references, list):
raise ValueError("expected an array")
for reference in references:
fields = {key.casefold(): value for key, value in reference.items()}
if any(fields.get(name) for name in (
"additionalproperties", "globalpropertiestoremove",
"setconfiguration", "setplatform", "settargetframework",
)):
raise ValueError("custom reference properties are unsupported; use the shared Configuration and project TFM")
raw = fields.get("fullpath") or fields.get("identity")
if not isinstance(raw, str) or not raw.strip():
raise ValueError("reference must have FullPath or Identity")
child = _project_path(root, project.parent / Path(raw))
pending.append((child, None))
# Outer builds omit references conditioned on an individual TFM.
if framework is None:
targets = data["Properties"].get("TargetFrameworks", "")
for target in targets.split(";"):
if target.strip():
pending.append((project, target.strip()))
except (ValueError, KeyError, TypeError, AttributeError) as exc:
raise ValidationFailure(
f"invalid evaluated ProjectReference metadata for {project}: {exc}"
) from exc
@@ -0,0 +1,86 @@
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
class ProjectKind(Enum):
DOTNET = "dotnet"
class ResultCheckKind(Enum):
TRX_HAS_TESTS = "trx_has_tests"
PROJECT_IS_NET8 = "project_is_net8"
@dataclass(frozen=True)
class DotnetConfig:
target: Path | None = None
configuration: str = "Debug"
test_projects: tuple[Path, ...] = ()
@dataclass(frozen=True)
class TDDConfig:
test_roots: tuple[Path, ...]
test_patterns: tuple[str, ...]
exclude: tuple[str, ...] = ()
@dataclass(frozen=True)
class HarnessConfig:
version: int
project_type: str
dotnet: DotnetConfig
tdd: TDDConfig
@dataclass(frozen=True)
class ProjectSelection:
kind: ProjectKind
project_file: Path
projects: tuple[Path, ...] = ()
@dataclass(frozen=True)
class DiscoveryResult:
selection: ProjectSelection | None
@dataclass(frozen=True)
class Toolchain:
dotnet: Path
sdk_version: str
@dataclass(frozen=True)
class CommandSpec:
argv: tuple[str, ...]
cwd: Path
stage: str
timeout_seconds: int = 1800
@dataclass(frozen=True)
class ResultCheck:
kind: ResultCheckKind
path: Path | None = None
@dataclass(frozen=True)
class ValidationStep:
command: CommandSpec
checks: tuple[ResultCheck, ...] = ()
@dataclass(frozen=True)
class ValidationPlan:
project_kind: ProjectKind
steps: tuple[ValidationStep, ...]
@dataclass(frozen=True)
class CommandResult:
command: CommandSpec
returncode: int
stdout: str
stderr: str
@@ -0,0 +1,58 @@
import fnmatch
import os
from pathlib import Path
EXCLUDED_PARTS = {
"bin", "obj", ".git", ".vs", ".harness", "testresults",
"generated", "vendor", "third_party", "external",
}
def is_excluded_path(path: Path, root: Path, extra=()) -> bool:
root = root.resolve()
candidate = path if path.is_absolute() else root / path
try:
relative = candidate.relative_to(root)
resolved_relative = candidate.resolve().relative_to(root)
except ValueError:
return True
parts = tuple(part.lower() for part in relative.parts)
if any(part in EXCLUDED_PARTS for part in parts):
return True
if parts[:2] == ("tests", "fixtures"):
return True
resolved_parts = tuple(part.lower() for part in resolved_relative.parts)
if any(part in EXCLUDED_PARTS for part in resolved_parts) or resolved_parts[:2] == ("tests", "fixtures"):
return True
name = relative.as_posix().lower()
return any(
fnmatch.fnmatchcase(name, pattern.replace("\\", "/").lower())
or name.startswith(pattern.replace("\\", "/").lower().rstrip("/") + "/")
for pattern in extra
)
def iter_source_files(root: Path):
"""Walk repository inputs, pruning artifacts and external/cyclic links."""
root = root.resolve()
seen = set()
def fail(error):
raise error
for directory, directories, files in os.walk(root, followlinks=False, onerror=fail):
current = Path(directory)
resolved = current.resolve()
if resolved in seen or is_excluded_path(current, root):
directories[:] = []
continue
seen.add(resolved)
directories[:] = sorted(
name for name in directories
if not is_excluded_path(current / name, root)
and (current / name).resolve() not in seen
)
for name in sorted(files):
path = current / name
if not is_excluded_path(path, root):
yield path
@@ -0,0 +1,170 @@
import json
import locale
import re
import xml.etree.ElementTree as ET
import subprocess
import time
from .models import CommandResult, ResultCheckKind
DIAGNOSTIC_LIMIT = 8000
class ValidationFailure(RuntimeError):
pass
def _decode(value):
if value is None:
return ""
if isinstance(value, str):
return value
for encoding in ("utf-8", locale.getpreferredencoding(False)):
try:
return value.decode(encoding)
except (UnicodeDecodeError, LookupError):
continue
return value.decode("utf-8", errors="replace")
def _check_result(check, result):
if check.kind is ResultCheckKind.PROJECT_IS_NET8:
try:
properties = json.loads(result.stdout)["Properties"]
target = properties.get("TargetFrameworks") or properties["TargetFramework"]
frameworks = target.split(";")
if not all(re.fullmatch(r"net8\.0(?:-[A-Za-z0-9.]+)?", item.strip()) for item in frameworks):
raise ValueError(f"expected net8.0 targets, got {target!r}")
except (ValueError, KeyError, TypeError, AttributeError) as exc:
raise ValidationFailure(f"cannot verify evaluated net8.0 target: {exc}") from exc
return
if check.kind is not ResultCheckKind.TRX_HAS_TESTS:
raise ValidationFailure("unsupported result check")
files = sorted(check.path.glob("*.trx"))
if not files:
raise ValidationFailure("test run produced no TRX results")
for path in files:
try:
if path.resolve().parent != check.path.resolve():
raise ValueError("TRX result is outside its result directory")
tree = ET.parse(path)
summary = tree.getroot().find("{*}ResultSummary")
if summary is None or summary.get("outcome") not in {"Completed", "Passed"}:
raise ValueError("test run did not complete successfully")
counters = summary.find("{*}Counters")
if counters is None:
raise ValueError("missing test counters")
counts = {key: int(value) for key, value in counters.attrib.items()}
if not (0 < counts["passed"] <= counts["executed"] <= counts["total"]):
raise ValueError("no executed passing tests or inconsistent counters")
if counts["failed"] or any(counts.get(key, 0) for key in
("error", "timeout", "aborted", "inconclusive", "passedButRunAborted", "notRunnable", "disconnected")):
raise ValueError("unsuccessful tests in TRX")
if any(value < 0 for value in counts.values()):
raise ValueError("negative test counters")
except (ET.ParseError, OSError, ValueError, KeyError) as exc:
raise ValidationFailure(f"invalid TRX {path.name}: {exc}") from exc
def _diagnostic(stdout, stderr):
diagnostic = (stdout + "\n" + stderr).rstrip()[-DIAGNOSTIC_LIMIT:]
return diagnostic.replace("\r\n", " | ").replace("\n", " | ")
def _command_context(argv, cwd):
return f"argv={list(argv)!r}; cwd={str(cwd)!r}"
def execute_plan(
plan,
root,
*,
env=None,
total_timeout_seconds=1800,
deadline=None,
run=subprocess.run,
clock=time.monotonic,
):
root = root.resolve()
if deadline is None:
deadline = clock() + total_timeout_seconds
results = []
for step in plan.steps:
cwd = step.command.cwd.resolve()
context = _command_context(step.command.argv, cwd)
try:
cwd.relative_to(root)
except ValueError as exc:
raise ValidationFailure(
f"{step.command.stage} working directory is outside the repository; "
f"{context}"
) from exc
for check in step.checks:
if check.kind is not ResultCheckKind.TRX_HAS_TESTS:
continue
if check.path is None:
raise ValidationFailure(f"{step.command.stage} missing results directory; {context}")
result_path = check.path.absolute()
if not result_path.resolve().is_relative_to(root) or result_path.resolve() != result_path:
raise ValidationFailure(f"{step.command.stage} results directory is outside repository or linked; {context}")
try:
result_path.mkdir(parents=True, exist_ok=False)
except OSError as exc:
raise ValidationFailure(f"{step.command.stage} requires a fresh results directory: {exc}; {context}") from exc
remaining = deadline - clock()
if remaining <= 0:
raise ValidationFailure(
f"{step.command.stage} timed out before it could start; {context}"
)
timeout = min(step.command.timeout_seconds, remaining)
try:
completed = run(
list(step.command.argv),
cwd=cwd,
env=env,
capture_output=True,
shell=False,
timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
stdout = _decode(exc.output)
stderr = _decode(exc.stderr)
diagnostic = _diagnostic(stdout, stderr)
message = (
f"{step.command.stage} timed out after {timeout} seconds; {context}"
)
if diagnostic:
message = f"{message}; output tail: {diagnostic}"
raise ValidationFailure(message) from exc
stdout = _decode(completed.stdout)
stderr = _decode(completed.stderr)
result = CommandResult(step.command, completed.returncode, stdout, stderr)
if completed.returncode != 0:
diagnostic = _diagnostic(stdout, stderr)
message = (
f"{step.command.stage} failed with exit code {completed.returncode}; "
f"{context}"
)
if diagnostic:
message = f"{message}; output tail: {diagnostic}"
raise ValidationFailure(message)
try:
for check in step.checks:
_check_result(check, result)
except ValidationFailure as exc:
diagnostic = _diagnostic(stdout, stderr)
message = (
f"{step.command.stage} result check failed: {exc}; {context}"
)
if diagnostic:
message = f"{message}; output tail: {diagnostic}"
raise ValidationFailure(message) from exc
results.append(result)
return tuple(results)
@@ -0,0 +1,36 @@
from fnmatch import fnmatch
from .paths import is_excluded_path, iter_source_files
CS_SUFFIXES = frozenset({".cs"})
def is_test_file(path, config):
return any(path.is_relative_to(root) for root in config.test_roots)
def evaluate_paths(paths, root, config):
root = root.resolve()
candidates = None
for raw in paths:
path = raw.resolve()
if not path.is_relative_to(root):
return f"TDD GUARD: '{path}' is outside the repository"
if path.suffix.lower() not in CS_SUFFIXES:
continue
if is_excluded_path(path, root, config.exclude) or is_test_file(path, config):
continue
if candidates is None:
candidates = tuple(p for p in iter_source_files(root) if p.suffix.lower() == ".cs")
roots = (*config.test_roots, path.parent / "tests", path.parent / "test")
names = tuple(pattern.format(stem=path.stem) for pattern in config.test_patterns)
found = any(
candidate.is_relative_to(test_root)
and any(fnmatch(candidate.name if "/" not in pattern else candidate.relative_to(test_root).as_posix(), pattern)
for pattern in names)
for test_root in roots for candidate in candidates
)
if not found:
return (f"TDD GUARD: '{path.name}' requires an existing test such as '{names[0]}'. "
"Add the test in a configured test root first.")
return None
@@ -0,0 +1,41 @@
import re
import shutil
import subprocess
import time
from pathlib import Path
from .models import Toolchain
class ToolchainError(RuntimeError):
pass
def _decode(value):
return value if isinstance(value, str) else (value or b"").decode("utf-8", errors="replace")
def discover_toolchain(root: Path, *, which=shutil.which, run=subprocess.run, deadline=None, clock=time.monotonic) -> Toolchain:
root = root.resolve()
executable = which("dotnet")
if executable is None or not Path(executable).is_file():
raise ToolchainError("dotnet not found; install .NET SDK 8 and check PATH and global.json")
executable = Path(executable).resolve()
argv = [str(executable), "--version"]
context = f"argv={argv!r}; cwd={str(root)!r}; check .NET SDK 8 and global.json"
timeout = 1800 if deadline is None else min(1800, deadline - clock())
if timeout <= 0:
raise ToolchainError(f"SDK probe timed out before it could start; {context}")
try:
result = run(argv, cwd=root, capture_output=True, check=False, shell=False, timeout=timeout)
except subprocess.TimeoutExpired as exc:
raise ToolchainError(f"SDK probe timed out; {context}") from exc
except OSError as exc:
raise ToolchainError(f"SDK probe could not start: {exc}; {context}") from exc
if result.returncode != 0:
diagnostic = (_decode(result.stdout) + "\n" + _decode(result.stderr)).strip()[-8000:]
raise ToolchainError(f"SDK probe failed with exit code {result.returncode}; {context}; output tail: {diagnostic}")
version = _decode(result.stdout).strip()
if not re.fullmatch(r"8\.0\.\d+", version):
raise ToolchainError(f"expected stable .NET SDK 8, got {version!r}; {context}")
return Toolchain(executable, version)
@@ -0,0 +1,592 @@
#!/usr/bin/env python3
"""
Harness Step Executor — phase 내 step을 순차 실행하고 자가 교정한다.
Usage:
python scripts/execute.py <phase-dir> [--push | --dry-run]
"""
import argparse
import contextlib
import json
import os
import subprocess
import sys
import threading
import time
import types
from datetime import datetime, timezone, timedelta
from pathlib import Path, PureWindowsPath
from typing import Optional
ROOT = Path(__file__).resolve().parent.parent
class CodexEnvironmentError(RuntimeError):
"""재시도로 해결할 수 없는 Codex CLI 환경 오류."""
class ContextError(ValueError):
"""Step 실행 전에 수정해야 하는 문서/설정 오류."""
@contextlib.contextmanager
def progress_indicator(label: str):
"""터미널 진행 표시기. with 문으로 사용하며 .elapsed 로 경과 시간을 읽는다."""
frames = "◐◓◑◒"
stop = threading.Event()
t0 = time.monotonic()
def _animate():
idx = 0
while not stop.wait(0.12):
sec = int(time.monotonic() - t0)
sys.stderr.write(f"\r{frames[idx % len(frames)]} {label} [{sec}s]")
sys.stderr.flush()
idx += 1
sys.stderr.write("\r" + " " * (len(label) + 20) + "\r")
sys.stderr.flush()
th = threading.Thread(target=_animate, daemon=True)
th.start()
info = types.SimpleNamespace(elapsed=0.0)
try:
yield info
finally:
stop.set()
th.join()
info.elapsed = time.monotonic() - t0
class StepExecutor:
"""Phase 디렉토리 안의 step들을 순차 실행하는 하네스."""
MAX_RETRIES = 3
FEAT_MSG = "feat({phase}): step {num} — {name}"
CHORE_MSG = "chore({phase}): step {num} output"
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
if not self._phase_dir.is_dir():
print(f"ERROR: {self._phase_dir} not found")
sys.exit(1)
self._index_file = self._phase_dir / "index.json"
if not self._index_file.exists():
print(f"ERROR: {self._index_file} not found")
sys.exit(1)
idx = self._read_json(self._index_file)
self._project = idx.get("project", "project")
self._phase_name = idx.get("phase", phase_dir_name)
self._total = len(idx["steps"])
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()
self._ensure_created_at()
self._execute_all_steps()
self._finalize()
# --- timestamps ---
def _stamp(self) -> str:
return datetime.now(self.TZ).strftime("%Y-%m-%dT%H:%M:%S%z")
# --- JSON I/O ---
@staticmethod
def _read_json(p: Path) -> dict:
return json.loads(p.read_text(encoding="utf-8"))
@staticmethod
def _write_json(p: Path, data: dict):
p.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
# --- git ---
def _run_git(self, *args) -> subprocess.CompletedProcess:
cmd = ["git"] + list(args)
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}"
r = self._run_git("rev-parse", "--abbrev-ref", "HEAD")
if r.returncode != 0:
print(f" ERROR: git을 사용할 수 없거나 git repo가 아닙니다.")
print(f" {r.stderr.strip()}")
sys.exit(1)
if r.stdout.strip() == branch:
return
r = self._run_git("rev-parse", "--verify", branch)
r = self._run_git("checkout", branch) if r.returncode == 0 else self._run_git("checkout", "-b", branch)
if r.returncode != 0:
print(f" ERROR: 브랜치 '{branch}' checkout 실패.")
print(f" {r.stderr.strip()}")
print(f" Hint: 변경사항을 stash하거나 commit한 후 다시 시도하세요.")
sys.exit(1)
print(f" Branch: {branch}")
def _commit_step(self, step_num: int, step_name: str):
output_rel = f"phases/{self._phase_dir_name}/step{step_num}-output.json"
index_rel = f"phases/{self._phase_dir_name}/index.json"
self._run_git("add", "-A")
self._run_git("reset", "HEAD", "--", output_rel)
self._run_git("reset", "HEAD", "--", index_rel)
if self._run_git("diff", "--cached", "--quiet").returncode != 0:
msg = self.FEAT_MSG.format(phase=self._phase_name, num=step_num, name=step_name)
r = self._run_git("commit", "-m", msg)
if r.returncode == 0:
print(f" Commit: {msg}")
else:
print(f" WARN: 코드 커밋 실패: {r.stderr.strip()}")
self._run_git("add", "-A")
if self._run_git("diff", "--cached", "--quiet").returncode != 0:
msg = self.CHORE_MSG.format(phase=self._phase_name, num=step_num)
r = self._run_git("commit", "-m", msg)
if r.returncode != 0:
print(f" WARN: housekeeping 커밋 실패: {r.stderr.strip()}")
# --- top-level index ---
def _update_top_index(self, status: str):
if not self._top_index_file.exists():
return
top = self._read_json(self._top_index_file)
ts = self._stamp()
for phase in top.get("phases", []):
if phase.get("dir") == self._phase_dir_name:
phase["status"] = status
ts_key = {"completed": "completed_at", "error": "failed_at", "blocked": "blocked_at"}.get(status)
if ts_key:
phase[ts_key] = ts
break
self._write_json(self._top_index_file, top)
# --- guardrails & context ---
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 _render_prompt(sections: list) -> str:
return "\n\n---\n\n".join(f"## {label}\n\n{body}" for label, body in sections)
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"1. 지정한 문서와 관련 코드를 읽고, 추가 자료는 필요할 때만 읽어라.\n"
f"2. 현재 step 범위만 수행하라. 아래 인계·step 기록은 허용된 산출물이다.\n"
f"3. 기존 테스트를 깨뜨리지 마라.\n"
f"4. AC(Acceptance Criteria) 검증을 직접 실행하라.\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" - 이번 세션에서 해결하지 못한 실패 → \"error\" + \"error_message\"에 원인·시도한 수정 기록\n"
f" - 사용자 개입이 필요한 경우 (API 키, 인증, 수동 설정 등) → \"blocked\" + \"blocked_reason\" 기록 후 즉시 중단\n"
f" 새 세션 재시도는 실행기가 최초 호출 포함 최대 {self.MAX_RETRIES}회 관리한다.\n"
f" 상태가 인계와 다르면 index와 실제 변경·로그를 확인하라.\n"
f"7. 변경사항을 직접 커밋하지 마라. Git 커밋과 timestamp는 실행기가 처리한다."
)
# --- Codex 호출 ---
def _invoke_codex(self, step: dict, prompt: str) -> dict:
step_num, step_name = step["step"], step["name"]
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",
"--json",
"--sandbox",
"workspace-write",
"--dangerously-bypass-hook-trust",
"--cd",
self._root,
"-",
]
try:
result = subprocess.run(
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})")
if result.stderr:
print(f" stderr: {result.stderr[:500]}")
output = {
"step": step_num, "name": step_name,
"exitCode": result.returncode,
"stdout": result.stdout, "stderr": result.stderr,
}
out_path = self._phase_dir / f"step{step_num}-output.json"
self._write_json(out_path, output)
return output
@staticmethod
def _codex_environment_failure(output: dict) -> Optional[str]:
if output.get("exitCode", 0) == 0:
return None
diagnostic = (
f"{output.get('stderr', '')}\n{output.get('stdout', '')}"
).lower()
markers = {
"not logged in": "Codex 인증이 필요합니다.",
"authentication": "Codex 인증에 실패했습니다.",
"unexpected argument": "현재 Codex CLI가 필요한 옵션을 지원하지 않습니다.",
"unrecognized option": "현재 Codex CLI가 필요한 옵션을 지원하지 않습니다.",
}
for marker, message in markers.items():
if marker in diagnostic:
return message
return None
# --- 헤더 & 검증 ---
def _print_header(self):
print(f"\n{'='*60}")
print(f" Harness Step Executor")
print(f" Phase: {self._phase_name} | Steps: {self._total}")
if self._auto_push:
print(f" Auto-push: enabled")
print(f"{'='*60}")
def _check_blockers(self):
index = self._read_json(self._index_file)
for s in reversed(index["steps"]):
if s["status"] == "error":
print(f"\n ✗ Step {s['step']} ({s['name']}) failed.")
print(f" Error: {s.get('error_message', 'unknown')}")
print(f" Fix and reset status to 'pending' to retry.")
sys.exit(1)
if s["status"] == "blocked":
print(f"\n ⏸ Step {s['step']} ({s['name']}) blocked.")
print(f" Reason: {s.get('blocked_reason', 'unknown')}")
print(f" Resolve and reset status to 'pending' to retry.")
sys.exit(2)
if s["status"] != "pending":
break
def _ensure_created_at(self):
index = self._read_json(self._index_file)
if "created_at" not in index:
index["created_at"] = self._stamp()
self._write_json(self._index_file, index)
# --- 실행 루프 ---
def _validate_completed_step(self, step: dict) -> tuple[bool, str]:
# Stop can halt a Codex turn with exit 0 even when its build failed.
# Independently verify before accepting the agent's completed state.
from hooks.stop_validation import _documentation_only_changes, run_validations
root = Path(self._root)
deadline = time.monotonic() + 1800
if self._validation_mode(step) == "docs-only" and _documentation_only_changes(
root, f"phases/{self._phase_dir_name}", deadline=deadline,
):
return True, ""
return run_validations(root, deadline=deadline)
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")
prev_error = None
for attempt in range(1, self.MAX_RETRIES + 1):
index = self._read_json(self._index_file)
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, 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)
status = next((s.get("status", "pending") for s in index["steps"] if s["step"] == step_num), "pending")
if status == "completed" and output["exitCode"] == 0:
with progress_indicator("Verifying completed step"):
valid, validation_error = self._validate_completed_step(step)
if not valid:
status = "error"
current = next(s for s in index["steps"] if s["step"] == step_num)
current.update(status="error", error_message=validation_error)
current.pop("completed_at", None)
current.pop("summary", None)
self._write_json(self._index_file, index)
ts = self._stamp()
if status == "completed" and output["exitCode"] == 0:
for s in index["steps"]:
if s["step"] == step_num:
s["completed_at"] = ts
self._write_json(self._index_file, index)
self._commit_step(step_num, step_name)
print(f" ✓ Step {step_num}: {step_name} [{elapsed}s]")
return True
if status == "blocked":
for s in index["steps"]:
if s["step"] == step_num:
s["blocked_at"] = ts
self._write_json(self._index_file, index)
reason = next((s.get("blocked_reason", "") for s in index["steps"] if s["step"] == step_num), "")
print(f" ⏸ Step {step_num}: {step_name} blocked [{elapsed}s]")
print(f" Reason: {reason}")
self._update_top_index("blocked")
sys.exit(2)
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"]:
if s["step"] == step_num:
s["status"] = "pending"
s.pop("error_message", None)
self._write_json(self._index_file, index)
prev_error = err_msg
print(f" ↻ Step {step_num}: retry {attempt}/{self.MAX_RETRIES} — {err_msg}")
else:
for s in index["steps"]:
if s["step"] == step_num:
s["status"] = "error"
s["error_message"] = f"[{self.MAX_RETRIES}회 시도 후 실패] {err_msg}"
s["failed_at"] = ts
self._write_json(self._index_file, index)
self._commit_step(step_num, step_name)
print(f" ✗ Step {step_num}: {step_name} failed after {self.MAX_RETRIES} attempts [{elapsed}s]")
print(f" Error: {err_msg}")
self._update_top_index("error")
sys.exit(1)
return False # unreachable
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)
if pending is None:
print("\n All steps completed!")
return
step_num = pending["step"]
for s in index["steps"]:
if s["step"] == step_num and "started_at" not in s:
s["started_at"] = self._stamp()
self._write_json(self._index_file, index)
break
self._execute_single_step(pending)
def _finalize(self):
index = self._read_json(self._index_file)
index["completed_at"] = self._stamp()
self._write_json(self._index_file, index)
self._update_top_index("completed")
self._run_git("add", "-A")
if self._run_git("diff", "--cached", "--quiet").returncode != 0:
msg = f"chore({self._phase_name}): mark phase completed"
r = self._run_git("commit", "-m", msg)
if r.returncode == 0:
print(f" ✓ {msg}")
if self._auto_push:
branch = f"feat-{self._phase_name}"
r = self._run_git("push", "-u", "origin", branch)
if r.returncode != 0:
print(f"\n ERROR: git push 실패: {r.stderr.strip()}")
sys.exit(1)
print(f" ✓ Pushed to origin/{branch}")
print(f"\n{'='*60}")
print(f" Phase '{self._phase_name}' completed!")
print(f"{'='*60}")
def main():
parser = argparse.ArgumentParser(description="Harness Step Executor")
parser.add_argument("phase_dir", help="Phase directory name (e.g. 0-mvp)")
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(dry_run=args.dry_run)
except (CodexEnvironmentError, ContextError) as exc:
print(f"ERROR: {exc}")
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Codex PreToolUse 정책: 위험 명령과 테스트 없는 구현 파일 수정을 차단한다."""
import json
import re
import sys
from pathlib import Path
from typing import Any
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
from dotnet_harness.config import ConfigError, load_config
from dotnet_harness.tdd_policy import CS_SUFFIXES, evaluate_paths
DANGEROUS_PATTERNS = (
re.compile(r"\bgit\s+reset\s+--hard\b", re.IGNORECASE),
re.compile(r"\bgit\s+push\b[^\n]*--force(?:-with-lease)?\b", re.IGNORECASE),
re.compile(r"\brm\s+-rf\b", re.IGNORECASE),
re.compile(
r"\bRemove-Item\b(?=[^\n]*-Recurse\b)(?=[^\n]*-Force\b)",
re.IGNORECASE,
),
re.compile(
r"\b(?:rmdir|rd)\b(?=[^\n]*/s\b)(?=[^\n]*/q\b)",
re.IGNORECASE,
),
re.compile(r"\bDROP\s+TABLE\b", re.IGNORECASE),
)
PATCH_PATH = re.compile(
r"^\*\*\* (?:(?:Add|Update|Delete) File:|Move to:) (?P<path>.+?)\s*$",
re.MULTILINE,
)
def _tool_input(payload: dict[str, Any]) -> dict[str, Any]:
value = payload.get("tool_input", {})
if isinstance(value, str):
return {"input": value}
return value if isinstance(value, dict) else {}
def _candidate_paths(payload: dict[str, Any], root: Path) -> list[Path]:
tool_input = _tool_input(payload)
raw_paths: list[str] = []
for key in ("path", "file_path"):
value = tool_input.get(key)
if isinstance(value, str) and value.strip():
raw_paths.append(value.strip())
edits = tool_input.get("edits")
if isinstance(edits, list):
for edit in edits:
if not isinstance(edit, dict):
continue
value = edit.get("path")
if isinstance(value, str) and value.strip():
raw_paths.append(value.strip())
for key in ("patch", "input", "command"):
value = tool_input.get(key)
if isinstance(value, str):
raw_paths.extend(match.group("path") for match in PATCH_PATH.finditer(value))
paths: list[Path] = []
for raw_path in raw_paths:
path = Path(raw_path)
paths.append(path.resolve() if path.is_absolute() else (root / path).resolve())
return list(dict.fromkeys(paths))
def evaluate(payload: dict[str, Any], root: Path) -> str | None:
"""Return a blocking reason, or None when the tool call is allowed."""
tool_name = str(payload.get("tool_name", "")).removeprefix("functions.")
tool_input = _tool_input(payload)
if tool_name in {"Bash", "shell_command", "PowerShell", "exec_command"}:
command = tool_input.get("command", tool_input.get("cmd", ""))
if isinstance(command, str) and any(
pattern.search(command) for pattern in DANGEROUS_PATTERNS
):
return "위험한 명령어가 감지되어 실행을 차단했습니다."
if tool_name not in {"apply_patch", "Edit", "MultiEdit", "Write"}:
return None
paths = _candidate_paths(payload, root)
try:
config = load_config(root)
except ConfigError as exc:
if any(path.suffix.lower() in CS_SUFFIXES for path in paths):
return f"TDD GUARD: .harness/config.json must be repaired: {exc}"
return None
return evaluate_paths(paths, root, config.tdd)
def main() -> int:
try:
payload = json.load(sys.stdin)
if not isinstance(payload, dict):
raise TypeError("hook input must be a JSON object")
cwd = payload.get("cwd")
if cwd is not None and not isinstance(cwd, str):
raise TypeError("hook cwd must be a string")
except (json.JSONDecodeError, TypeError) as exc:
print(f"TDD GUARD: hook 입력을 해석하지 못해 검사를 건너뜁니다: {exc}", file=sys.stderr)
return 0
root = Path(payload.get("cwd") or Path.cwd()).resolve()
try:
reason = evaluate(payload, root)
except OSError as exc:
reason = f"TDD GUARD: repository inspection failed: {exc}"
if reason:
# PowerShell can translate a native exit code 2 into 1. JSON preserves
# the deny decision across the Windows command-hook shell boundary.
print(json.dumps({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""Codex Stop hook: validate discovered .NET 8 C# projects."""
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path, PurePosixPath, PureWindowsPath
SCRIPTS = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SCRIPTS))
from dotnet_harness.adapters.base import AdapterError
from dotnet_harness.adapters.dotnet import DotnetAdapter
from dotnet_harness.config import ConfigError, load_config
from dotnet_harness.discovery import DiscoveryError, discover_project
from dotnet_harness.metadata import validate_project_graph
from dotnet_harness.process import ValidationFailure, execute_plan
from dotnet_harness.toolchain import ToolchainError, discover_toolchain
REENTRY_ENV = "CODEX_STOP_VALIDATION_ACTIVE"
TOTAL_TIMEOUT_SECONDS = 1800
def _remaining(deadline, clock, stage):
remaining = deadline - clock()
if remaining <= 0:
raise ValidationFailure(f"{stage} timed out before it could start")
return remaining
def _project_root(
cwd: Path,
*,
deadline: float,
run=subprocess.run,
clock=time.monotonic,
) -> Path:
argv = ["git", "rev-parse", "--show-toplevel"]
resolved_cwd = cwd.resolve()
try:
result = run(
argv,
cwd=resolved_cwd,
capture_output=True,
text=True,
encoding="utf-8",
shell=False,
timeout=_remaining(deadline, clock, "repository discovery"),
)
except subprocess.TimeoutExpired as exc:
raise ValidationFailure(
f"repository discovery timed out; argv={argv!r}; "
f"cwd={str(resolved_cwd)!r}"
) from exc
if result.returncode == 0 and result.stdout.strip():
return Path(result.stdout.strip()).resolve()
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 product validation."""
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,
*,
deadline: float | None = None,
clock=time.monotonic,
) -> tuple[bool, str]:
"""Build and test a discovered .NET 8 C# project, when one exists."""
if deadline is None:
deadline = clock() + TOTAL_TIMEOUT_SECONDS
try:
config = load_config(root)
discovery = discover_project(root, config)
if discovery.selection is None:
return True, "No C# product found; product validation was not run."
selection = discovery.selection
tools = discover_toolchain(
root,
deadline=deadline,
clock=clock,
)
validate_project_graph(root, selection, config, tools, deadline=deadline, clock=clock)
adapter = DotnetAdapter()
plan = adapter.create_plan(root, selection, config, tools)
child_env = os.environ.copy()
child_env[REENTRY_ENV] = "1"
execute_plan(
plan,
root,
env=child_env,
deadline=deadline,
clock=clock,
)
return True, ""
except (
ConfigError,
DiscoveryError,
ToolchainError,
AdapterError,
ValidationFailure,
OSError,
) as exc:
return False, str(exc)
def _emit_stop_response(message: str) -> None:
print(
json.dumps(
{
"continue": False,
"stopReason": message,
"systemMessage": message,
},
ensure_ascii=False,
)
)
def main(*, run=subprocess.run, clock=time.monotonic) -> int:
if os.environ.get(REENTRY_ENV) == "1":
return 0
deadline = clock() + TOTAL_TIMEOUT_SECONDS
try:
root = _project_root(
Path.cwd(),
deadline=deadline,
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}")
return 0
if ok:
if message:
print(message, file=sys.stderr)
return 0
_emit_stop_response(message)
return 0
if __name__ == "__main__":
raise SystemExit(main())