diff --git a/Codex/Coding/harness framework cpp old/.agents/skills/harness/SKILL.md b/Codex/Coding/harness framework cpp old/.agents/skills/harness/SKILL.md new file mode 100644 index 0000000..49519ee --- /dev/null +++ b/Codex/Coding/harness framework cpp old/.agents/skills/harness/SKILL.md @@ -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": "", + "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`을 삭제한 + 뒤 재실행 diff --git a/Codex/Coding/harness framework cpp old/.agents/skills/review/SKILL.md b/Codex/Coding/harness framework cpp old/.agents/skills/review/SKILL.md new file mode 100644 index 0000000..9f695da --- /dev/null +++ b/Codex/Coding/harness framework cpp old/.agents/skills/review/SKILL.md @@ -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이 +없으면 발견된 문제가 없다고 명시하고 남아 있는 검증 공백을 설명한다. diff --git a/Codex/Coding/harness framework cpp old/.codex/hooks.json b/Codex/Coding/harness framework cpp old/.codex/hooks.json new file mode 100644 index 0000000..598631c --- /dev/null +++ b/Codex/Coding/harness framework cpp old/.codex/hooks.json @@ -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" + } + ] + } + ] + } +} diff --git a/Codex/Coding/harness framework cpp old/.gitignore b/Codex/Coding/harness framework cpp old/.gitignore new file mode 100644 index 0000000..024d626 --- /dev/null +++ b/Codex/Coding/harness framework cpp old/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +.pytest_cache/ +*.py[cod] +.harness/build/ diff --git a/Codex/Coding/harness framework cpp old/.harness/config.example.json b/Codex/Coding/harness framework cpp old/.harness/config.example.json new file mode 100644 index 0000000..70463a7 --- /dev/null +++ b/Codex/Coding/harness framework cpp old/.harness/config.example.json @@ -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/**" + ] + } +} diff --git a/Codex/Coding/harness framework cpp old/AGENTS.md b/Codex/Coding/harness framework cpp old/AGENTS.md new file mode 100644 index 0000000..ff87069 --- /dev/null +++ b/Codex/Coding/harness framework cpp old/AGENTS.md @@ -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`과 프로젝트 문서를 +우선한다. diff --git a/Codex/Coding/harness framework cpp old/docs/ADR.md b/Codex/Coding/harness framework cpp old/docs/ADR.md new file mode 100644 index 0000000..7b51333 --- /dev/null +++ b/Codex/Coding/harness framework cpp old/docs/ADR.md @@ -0,0 +1,21 @@ +# Architecture Decision Records + +## 철학 +{프로젝트의 핵심 가치관 (예: MVP 속도 최우선. 외부 의존성 최소화. 작동하는 최소 구현을 선택.)} + +--- + +### ADR-001: {결정 사항 (예: Next.js App Router 선택)} +**결정**: {뭘 선택했는지} +**이유**: {왜 선택했는지} +**트레이드오프**: {뭘 포기했는지} + +### ADR-002: {결정 사항} +**결정**: {뭘 선택했는지} +**이유**: {왜 선택했는지} +**트레이드오프**: {뭘 포기했는지} + +### ADR-003: {결정 사항} +**결정**: {뭘 선택했는지} +**이유**: {왜 선택했는지} +**트레이드오프**: {뭘 포기했는지} \ No newline at end of file diff --git a/Codex/Coding/harness framework cpp old/docs/ARCHITECTURE.md b/Codex/Coding/harness framework cpp old/docs/ARCHITECTURE.md new file mode 100644 index 0000000..1c84a7d --- /dev/null +++ b/Codex/Coding/harness framework cpp old/docs/ARCHITECTURE.md @@ -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)} \ No newline at end of file diff --git a/Codex/Coding/harness framework cpp old/docs/HARNESS.md b/Codex/Coding/harness framework cpp old/docs/HARNESS.md new file mode 100644 index 0000000..248134f --- /dev/null +++ b/Codex/Coding/harness framework cpp old/docs/HARNESS.md @@ -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 +python scripts/execute.py --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는 + 별도의 로그 파일을 만들지 않는다. diff --git a/Codex/Coding/harness framework cpp old/docs/HARNESS_WORKFLOW.md b/Codex/Coding/harness framework cpp old/docs/HARNESS_WORKFLOW.md new file mode 100644 index 0000000..efa507a --- /dev/null +++ b/Codex/Coding/harness framework cpp old/docs/HARNESS_WORKFLOW.md @@ -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 + - +``` + +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 -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 +cmake --build --preset +ctest --preset --show-only=json-v1 +ctest --preset --output-on-failure +``` + +이 경우 모든 명령은 `cmake.sourceDir`에서 실행하고 compiler metadata 검사는 설정한 +`binaryDir`에서 수행한다. + +### 9.3 직접 MSBuild + +[MSBuild adapter](../scripts/msvc_harness/adapters/msbuild.py)는 다음 순서로 실행한다. + +```powershell +MSBuild.exe /m /nologo ` + /p:Configuration= ` + /p:Platform= + + +``` + +직접 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가 남아 있으면 실제 작업 지시와 충돌할 수 있다. diff --git a/Codex/Coding/harness framework cpp old/docs/PRD.md b/Codex/Coding/harness framework cpp old/docs/PRD.md new file mode 100644 index 0000000..9006215 --- /dev/null +++ b/Codex/Coding/harness framework cpp old/docs/PRD.md @@ -0,0 +1,21 @@ +# PRD: {프로젝트명} + +## 목표 +{이 프로젝트가 해결하려는 문제를 한 줄로 요약} + +## 사용자 +{누가 이 제품을 쓰는지} + +## 핵심 기능 +1. {기능 1} +2. {기능 2} +3. {기능 3} + +## MVP 제외 사항 +- {안 만들 것 1} +- {안 만들 것 2} +- {안 만들 것 3} + +## 디자인 +- {디자인 방향 (예: 다크모드 고정, 미니멀)} +- {색상 (예: 무채색 + 포인트 1가지)} \ No newline at end of file diff --git a/Codex/Coding/harness framework cpp/pytest.ini b/Codex/Coding/harness framework cpp old/pytest.ini similarity index 100% rename from Codex/Coding/harness framework cpp/pytest.ini rename to Codex/Coding/harness framework cpp old/pytest.ini diff --git a/Codex/Coding/harness framework cpp old/scripts/execute.py b/Codex/Coding/harness framework cpp old/scripts/execute.py new file mode 100644 index 0000000..3ca984b --- /dev/null +++ b/Codex/Coding/harness framework cpp old/scripts/execute.py @@ -0,0 +1,481 @@ +#!/usr/bin/env python3 +""" +Harness Step Executor — phase 내 step을 순차 실행하고 자가 교정한다. + +Usage: + python scripts/execute.py [--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() diff --git a/Codex/Coding/harness framework cpp old/scripts/hooks/pre_tool_use.py b/Codex/Coding/harness framework cpp old/scripts/hooks/pre_tool_use.py new file mode 100644 index 0000000..bca43ba --- /dev/null +++ b/Codex/Coding/harness framework cpp old/scripts/hooks/pre_tool_use.py @@ -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.+?)\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()) diff --git a/Codex/Coding/harness framework cpp old/scripts/hooks/stop_validation.py b/Codex/Coding/harness framework cpp old/scripts/hooks/stop_validation.py new file mode 100644 index 0000000..5c58c8c --- /dev/null +++ b/Codex/Coding/harness framework cpp old/scripts/hooks/stop_validation.py @@ -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()) diff --git a/Codex/Coding/harness framework cpp old/scripts/msvc_harness/__init__.py b/Codex/Coding/harness framework cpp old/scripts/msvc_harness/__init__.py new file mode 100644 index 0000000..c59cfff --- /dev/null +++ b/Codex/Coding/harness framework cpp old/scripts/msvc_harness/__init__.py @@ -0,0 +1,3 @@ +from .config import ConfigError, load_config + +__all__ = ["ConfigError", "load_config"] diff --git a/Codex/Coding/harness framework cpp old/scripts/msvc_harness/adapters/__init__.py b/Codex/Coding/harness framework cpp old/scripts/msvc_harness/adapters/__init__.py new file mode 100644 index 0000000..c343612 --- /dev/null +++ b/Codex/Coding/harness framework cpp old/scripts/msvc_harness/adapters/__init__.py @@ -0,0 +1,5 @@ +from .base import AdapterError, ValidationAdapter +from .cmake import CMakeAdapter +from .msbuild import MSBuildAdapter + +__all__ = ["AdapterError", "CMakeAdapter", "MSBuildAdapter", "ValidationAdapter"] diff --git a/Codex/Coding/harness framework cpp old/scripts/msvc_harness/adapters/base.py b/Codex/Coding/harness framework cpp old/scripts/msvc_harness/adapters/base.py new file mode 100644 index 0000000..0c97c4e --- /dev/null +++ b/Codex/Coding/harness framework cpp old/scripts/msvc_harness/adapters/base.py @@ -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 diff --git a/Codex/Coding/harness framework cpp old/scripts/msvc_harness/adapters/cmake.py b/Codex/Coding/harness framework cpp old/scripts/msvc_harness/adapters/cmake.py new file mode 100644 index 0000000..f7d449d --- /dev/null +++ b/Codex/Coding/harness framework cpp old/scripts/msvc_harness/adapters/cmake.py @@ -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")), + ), + ) diff --git a/Codex/Coding/harness framework cpp old/scripts/msvc_harness/adapters/msbuild.py b/Codex/Coding/harness framework cpp old/scripts/msvc_harness/adapters/msbuild.py new file mode 100644 index 0000000..00c95e0 --- /dev/null +++ b/Codex/Coding/harness framework cpp old/scripts/msvc_harness/adapters/msbuild.py @@ -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)) diff --git a/Codex/Coding/harness framework cpp old/scripts/msvc_harness/config.py b/Codex/Coding/harness framework cpp old/scripts/msvc_harness/config.py new file mode 100644 index 0000000..dd9ef43 --- /dev/null +++ b/Codex/Coding/harness framework cpp old/scripts/msvc_harness/config.py @@ -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), + ) diff --git a/Codex/Coding/harness framework cpp old/scripts/msvc_harness/discovery.py b/Codex/Coding/harness framework cpp old/scripts/msvc_harness/discovery.py new file mode 100644 index 0000000..282c69d --- /dev/null +++ b/Codex/Coding/harness framework cpp old/scripts/msvc_harness/discovery.py @@ -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, ()) diff --git a/Codex/Coding/harness framework cpp old/scripts/msvc_harness/models.py b/Codex/Coding/harness framework cpp old/scripts/msvc_harness/models.py new file mode 100644 index 0000000..1c03085 --- /dev/null +++ b/Codex/Coding/harness framework cpp old/scripts/msvc_harness/models.py @@ -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 diff --git a/Codex/Coding/harness framework cpp old/scripts/msvc_harness/process.py b/Codex/Coding/harness framework cpp old/scripts/msvc_harness/process.py new file mode 100644 index 0000000..0911b13 --- /dev/null +++ b/Codex/Coding/harness framework cpp old/scripts/msvc_harness/process.py @@ -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) diff --git a/Codex/Coding/harness framework cpp old/scripts/msvc_harness/tdd_policy.py b/Codex/Coding/harness framework cpp old/scripts/msvc_harness/tdd_policy.py new file mode 100644 index 0000000..b5f5b8c --- /dev/null +++ b/Codex/Coding/harness framework cpp old/scripts/msvc_harness/tdd_policy.py @@ -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 diff --git a/Codex/Coding/harness framework cpp old/scripts/msvc_harness/toolchain.py b/Codex/Coding/harness framework cpp old/scripts/msvc_harness/toolchain.py new file mode 100644 index 0000000..b1d63d0 --- /dev/null +++ b/Codex/Coding/harness framework cpp old/scripts/msvc_harness/toolchain.py @@ -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) diff --git a/Codex/Coding/harness framework cpp/.agents/skills/harness/SKILL.md b/Codex/Coding/harness framework cpp/.agents/skills/harness/SKILL.md index 49519ee..e1150e4 100644 --- a/Codex/Coding/harness framework cpp/.agents/skills/harness/SKILL.md +++ b/Codex/Coding/harness framework cpp/.agents/skills/harness/SKILL.md @@ -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": "", - "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: -## 읽어야 할 파일 +## 목표와 범위 +<한 가지 책임과 범위 밖 항목> -먼저 아래 파일을 읽고 프로젝트의 아키텍처와 설계 의도를 파악하라: +## 관련 파일 +- `` -- `/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 +python scripts/execute.py --dry-run +python scripts/execute.py --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를 제거한다. diff --git a/Codex/Coding/harness framework cpp/.agents/skills/review/SKILL.md b/Codex/Coding/harness framework cpp/.agents/skills/review/SKILL.md index 9f695da..0d96116 100644 --- a/Codex/Coding/harness framework cpp/.agents/skills/review/SKILL.md +++ b/Codex/Coding/harness framework cpp/.agents/skills/review/SKILL.md @@ -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이 없으면 발견된 문제가 없다고 쓰고 +남은 검증 공백을 밝힌다. diff --git a/Codex/Coding/harness framework cpp/AGENTS.md b/Codex/Coding/harness framework cpp/AGENTS.md index ff87069..cda0ac1 100644 --- a/Codex/Coding/harness framework cpp/AGENTS.md +++ b/Codex/Coding/harness framework cpp/AGENTS.md @@ -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 /m /p:Configuration=Debug /p:Platform=x64 + ``` - -### 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`과 프로젝트 문서를 -우선한다. diff --git a/Codex/Coding/harness framework cpp/docs/ADR.md b/Codex/Coding/harness framework cpp/docs/ADR.md index 7b51333..0c1fd63 100644 --- a/Codex/Coding/harness framework cpp/docs/ADR.md +++ b/Codex/Coding/harness framework cpp/docs/ADR.md @@ -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: {결정 사항} -**결정**: {뭘 선택했는지} -**이유**: {왜 선택했는지} -**트레이드오프**: {뭘 포기했는지} \ No newline at end of file +- **상태:** 승인 +- **결정:** public header와 implementation의 의존성 방향을 유지하고 제품 구현, + 공개 header, 테스트를 각각 `src/`, `include/`, `tests/`에 둔다. +- **이유:** 공개 계약과 구현 세부사항의 결합을 막기 위해서다. + +## 새 결정 형식 + +새 ADR에는 상태, 결정, 이유, 대안 또는 trade-off, 영향을 받는 경로를 기록한다. +Harness 사용법과 실행 상태는 제품 ADR 대신 Harness 문서와 phase index에 기록한다. diff --git a/Codex/Coding/harness framework cpp/docs/ARCHITECTURE.md b/Codex/Coding/harness framework cpp/docs/ARCHITECTURE.md index 1c84a7d..08266ec 100644 --- a/Codex/Coding/harness framework cpp/docs/ARCHITECTURE.md +++ b/Codex/Coding/harness framework cpp/docs/ARCHITECTURE.md @@ -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)} \ No newline at end of file +실제 디렉터리나 build metadata가 추가되면 확인된 구조만 이 문서에 기록한다. +Harness의 Python 모듈 구조와 phase 운영 절차는 제품 아키텍처에 포함하지 않는다. diff --git a/Codex/Coding/harness framework cpp/docs/ENVIRONMENT_SETUP.md b/Codex/Coding/harness framework cpp/docs/ENVIRONMENT_SETUP.md new file mode 100644 index 0000000..202126b --- /dev/null +++ b/Codex/Coding/harness framework cpp/docs/ENVIRONMENT_SETUP.md @@ -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 - +``` + +`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//index.json`, `stepN.md`를 준비한다. +`context_files`에는 존재하는 파일만 넣는다. 현재 checkout에는 기존 문서가 참조하는 +`docs/phase/README.md`도 없으므로 기록은 실행기가 명시한 다음 내용을 기준으로 준비한다. + +- `docs/phase//handoff.md`: 현재 상황, 결정과 이유, 미해결 문제, 검증 결과, + 다음 시작점과 상세 기록 링크를 약 60줄로 유지한다. +- `docs/phase//stepN.md`: 변경 파일, 결정, 검증, 실패한 접근과 이유를 기록한다. +- 실행 상태의 기준은 `phases//index.json`이며 인계 기록은 상태를 대신하지 않는다. + +``를 실제 디렉터리 이름으로 바꾸어 실행한다. + +```powershell +python -X utf8 scripts/execute.py --dry-run +python -X utf8 scripts/execute.py +``` + +`--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)을 참고한다. diff --git a/Codex/Coding/harness framework cpp/docs/HARNESS.md b/Codex/Coding/harness framework cpp/docs/HARNESS.md index 248134f..e941070 100644 --- a/Codex/Coding/harness framework cpp/docs/HARNESS.md +++ b/Codex/Coding/harness framework cpp/docs/HARNESS.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 --dry-run python scripts/execute.py python scripts/execute.py --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 /m /nologo /p:Configuration=Debug /p:Platform=x64 + ``` -## 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는 - 별도의 로그 파일을 만들지 않는다. diff --git a/Codex/Coding/harness framework cpp/docs/HARNESS_WORKFLOW.md b/Codex/Coding/harness framework cpp/docs/HARNESS_WORKFLOW.md index efa507a..c713b14 100644 --- a/Codex/Coding/harness framework cpp/docs/HARNESS_WORKFLOW.md +++ b/Codex/Coding/harness framework cpp/docs/HARNESS_WORKFLOW.md @@ -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 + / + index.json + step0.md +docs/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 --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 - - +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 -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 -cmake --build --preset -ctest --preset --show-only=json-v1 -ctest --preset --output-on-failure -``` - -이 경우 모든 명령은 `cmake.sourceDir`에서 실행하고 compiler metadata 검사는 설정한 -`binaryDir`에서 수행한다. - -### 9.3 직접 MSBuild - -[MSBuild adapter](../scripts/msvc_harness/adapters/msbuild.py)는 다음 순서로 실행한다. - -```powershell -MSBuild.exe /m /nologo ` - /p:Configuration= ` - /p:Platform= - - -``` - -직접 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 성공은 +검증되지 않았다. diff --git a/Codex/Coding/harness framework cpp/docs/PRD.md b/Codex/Coding/harness framework cpp/docs/PRD.md index 9006215..edda3a2 100644 --- a/Codex/Coding/harness framework cpp/docs/PRD.md +++ b/Codex/Coding/harness framework cpp/docs/PRD.md @@ -1,21 +1,33 @@ -# PRD: {프로젝트명} +# C++ 제품 PRD + +Harness 운영 요구사항은 이 문서의 제품 요구사항에 포함하지 않는다. ## 목표 -{이 프로젝트가 해결하려는 문제를 한 줄로 요약} + +미정 — 제품 요구사항 정의 시 확정. ## 사용자 -{누가 이 제품을 쓰는지} + +미정 — 제품 요구사항 정의 시 확정. ## 핵심 기능 -1. {기능 1} -2. {기능 2} -3. {기능 3} -## MVP 제외 사항 -- {안 만들 것 1} -- {안 만들 것 2} -- {안 만들 것 3} +미정 — 제품 요구사항 정의 시 확정. -## 디자인 -- {디자인 방향 (예: 다크모드 고정, 미니멀)} -- {색상 (예: 무채색 + 포인트 1가지)} \ No newline at end of file +## 제약 + +- MSVC 기반 C++ 제품으로 개발한다. +- MSVC toolset, C++ 표준, 지원 플랫폼과 성능·보안·호환성 조건은 미정이다. +- public header와 implementation의 의존성 방향을 유지한다. +- 새 기능과 버그 수정은 실패하는 테스트를 먼저 확인한다. +- 새 빌드 경고를 추가하지 않는다. + +## 완료 조건 + +미정 — 제품 기능별 실행 가능한 build/test 명령과 수용 조건을 정의할 때 확정. + +## 제외 범위 + +- Harness 실행기, 훅, phase 관리와 문맥 경량화는 개발 도구의 책임이며 제품 기능이 + 아니다. +- 구체적인 제품 기능, API, UI와 배포 범위는 요구사항이 정해질 때까지 확정하지 않는다. diff --git a/Codex/Coding/harness framework cpp/scripts/execute.py b/Codex/Coding/harness framework cpp/scripts/execute.py index 3ca984b..c273fc0 100644 --- a/Codex/Coding/harness framework cpp/scripts/execute.py +++ b/Codex/Coding/harness framework cpp/scripts/execute.py @@ -3,7 +3,7 @@ Harness Step Executor — phase 내 step을 순차 실행하고 자가 교정한다. Usage: - python scripts/execute.py [--push] + python scripts/execute.py [--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) diff --git a/Codex/Coding/harness framework cpp/scripts/hooks/stop_validation.py b/Codex/Coding/harness framework cpp/scripts/hooks/stop_validation.py index 5c58c8c..7d8cbd7 100644 --- a/Codex/Coding/harness framework cpp/scripts/hooks/stop_validation.py +++ b/Codex/Coding/harness framework cpp/scripts/hooks/stop_validation.py @@ -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}") diff --git a/Codex/Coding/harness framework cpp/scripts/msvc_harness/discovery.py b/Codex/Coding/harness framework cpp/scripts/msvc_harness/discovery.py index 282c69d..5cb188b 100644 --- a/Codex/Coding/harness framework cpp/scripts/msvc_harness/discovery.py +++ b/Codex/Coding/harness framework cpp/scripts/msvc_harness/discovery.py @@ -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" diff --git a/Codex/Coding/harness framework cpp/scripts/msvc_harness/tdd_policy.py b/Codex/Coding/harness framework cpp/scripts/msvc_harness/tdd_policy.py index b5f5b8c..df2b8ac 100644 --- a/Codex/Coding/harness framework cpp/scripts/msvc_harness/tdd_policy.py +++ b/Codex/Coding/harness framework cpp/scripts/msvc_harness/tdd_policy.py @@ -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 " diff --git a/Codex/Coding/harness framework cs/.agents/skills/harness/SKILL.md b/Codex/Coding/harness framework cs/.agents/skills/harness/SKILL.md new file mode 100644 index 0000000..6c05f2c --- /dev/null +++ b/Codex/Coding/harness framework cs/.agents/skills/harness/SKILL.md @@ -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: + +## 목표와 범위 +<한 가지 책임과 범위 밖 항목> + +## 관련 파일 +- `` + +## 핵심 제약 +- <유지할 계약과 이유> + +## 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 +python scripts/execute.py --dry-run +python scripts/execute.py --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를 제거한다. diff --git a/Codex/Coding/harness framework cs/.agents/skills/review/SKILL.md b/Codex/Coding/harness framework cs/.agents/skills/review/SKILL.md new file mode 100644 index 0000000..df66b3f --- /dev/null +++ b/Codex/Coding/harness framework cs/.agents/skills/review/SKILL.md @@ -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이 없으면 발견된 문제가 없다고 쓰고 +남은 검증 공백을 밝힌다. diff --git a/Codex/Coding/harness framework cs/.codex/hooks.json b/Codex/Coding/harness framework cs/.codex/hooks.json new file mode 100644 index 0000000..4f15924 --- /dev/null +++ b/Codex/Coding/harness framework cs/.codex/hooks.json @@ -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" + } + ] + } + ] + } +} diff --git a/Codex/Coding/harness framework cs/.gitignore b/Codex/Coding/harness framework cs/.gitignore new file mode 100644 index 0000000..d50f762 --- /dev/null +++ b/Codex/Coding/harness framework cs/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +.pytest_cache/ +*.py[cod] +.harness/build/ +bin/ +obj/ +.vs/ +TestResults/ +.harness/test-results/ diff --git a/Codex/Coding/harness framework cs/.harness/config.example.json b/Codex/Coding/harness framework cs/.harness/config.example.json new file mode 100644 index 0000000..b8653e0 --- /dev/null +++ b/Codex/Coding/harness framework cs/.harness/config.example.json @@ -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": [] + } +} diff --git a/Codex/Coding/harness framework cs/AGENTS.md b/Codex/Coding/harness framework cs/AGENTS.md new file mode 100644 index 0000000..e998ce3 --- /dev/null +++ b/Codex/Coding/harness framework cs/AGENTS.md @@ -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//`, 테스트 `tests/.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 -getProperty:TargetFramework,TargetFrameworks -getItem:ProjectReference -p:Configuration=Debug +dotnet restore -p:Configuration=Debug +dotnet restore -p:Configuration=Debug +dotnet build -c Debug --no-restore -warnaserror +dotnet build -c Debug --no-restore -warnaserror +dotnet test -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)를 따른다. diff --git a/Codex/Coding/harness framework cs/docs/ADR.md b/Codex/Coding/harness framework cs/docs/ADR.md new file mode 100644 index 0000000..5febc71 --- /dev/null +++ b/Codex/Coding/harness framework cs/docs/ADR.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//`와 `tests/.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에 기록한다. diff --git a/Codex/Coding/harness framework cs/docs/ARCHITECTURE.md b/Codex/Coding/harness framework cs/docs/ARCHITECTURE.md new file mode 100644 index 0000000..6dc5fc8 --- /dev/null +++ b/Codex/Coding/harness framework cs/docs/ARCHITECTURE.md @@ -0,0 +1,25 @@ +# 제품 아키텍처 + +## 확정된 기술 경계 + +제품은 C#·.NET 8을 사용한다. 실제 업무 프로젝트, entry point, 런타임 데이터 흐름과 +배포 구조는 아직 미정이다. 특정 UI 프레임워크, 계층, DI나 async 구조를 강제하지 않는다. + +다음은 제품 생성 시의 **권장 배치**이며 현재 구현된 제품 구조를 뜻하지 않는다. + +```text +src// 제품 C# 코드와 SDK 스타일 .csproj +tests/.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)에 둔다. diff --git a/Codex/Coding/harness framework cs/docs/ENVIRONMENT_SETUP.md b/Codex/Coding/harness framework cs/docs/ENVIRONMENT_SETUP.md new file mode 100644 index 0000000..8816307 --- /dev/null +++ b/Codex/Coding/harness framework cs/docs/ENVIRONMENT_SETUP.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 - +``` + +저장소는 최소 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//`와 `tests/.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//index.json`, `stepN.md`를 준비한다. +`context_files`에는 존재하는 파일만 넣는다. [phase 기록 형식](phase/README.md)에 따라 +handoff는 약 60줄, step 기록에는 변경·결정·실제 검증·유효한 실패 접근을 남긴다. +실행 상태의 기준은 항상 phase index다. + +``를 실제 디렉터리 이름으로 바꾼다. + +```powershell +python -X utf8 scripts/execute.py --dry-run +python -X utf8 scripts/execute.py +``` + +`--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)을 따른다. diff --git a/Codex/Coding/harness framework cs/docs/HARNESS.md b/Codex/Coding/harness framework cs/docs/HARNESS.md new file mode 100644 index 0000000..16cd3c7 --- /dev/null +++ b/Codex/Coding/harness framework cs/docs/HARNESS.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 --dry-run +python -X utf8 scripts/execute.py +python -X utf8 scripts/execute.py --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=`을 실행한다. 다중 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 기록에 남긴다. diff --git a/Codex/Coding/harness framework cs/docs/HARNESS_WORKFLOW.md b/Codex/Coding/harness framework cs/docs/HARNESS_WORKFLOW.md new file mode 100644 index 0000000..47d40df --- /dev/null +++ b/Codex/Coding/harness framework cs/docs/HARNESS_WORKFLOW.md @@ -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 + / + index.json + step0.md +docs/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 --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 기록에서 확인한다. diff --git a/Codex/Coding/harness framework cs/docs/PRD.md b/Codex/Coding/harness framework cs/docs/PRD.md new file mode 100644 index 0000000..ecee6fd --- /dev/null +++ b/Codex/Coding/harness framework cs/docs/PRD.md @@ -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 전용 실행은 이번 템플릿 범위 밖이다. diff --git a/Codex/Coding/harness framework cs/global.json b/Codex/Coding/harness framework cs/global.json new file mode 100644 index 0000000..b60ea4b --- /dev/null +++ b/Codex/Coding/harness framework cs/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "8.0.424", + "rollForward": "latestPatch", + "allowPrerelease": false + } +} diff --git a/Codex/Coding/harness framework cs/pytest.ini b/Codex/Coding/harness framework cs/pytest.ini new file mode 100644 index 0000000..d5fef46 --- /dev/null +++ b/Codex/Coding/harness framework cs/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests/harness diff --git a/Codex/Coding/harness framework cs/scripts/dotnet_harness/__init__.py b/Codex/Coding/harness framework cs/scripts/dotnet_harness/__init__.py new file mode 100644 index 0000000..3db9b7d --- /dev/null +++ b/Codex/Coding/harness framework cs/scripts/dotnet_harness/__init__.py @@ -0,0 +1 @@ +"""Windows .NET 8 C# product validation support.""" diff --git a/Codex/Coding/harness framework cs/scripts/dotnet_harness/adapters/__init__.py b/Codex/Coding/harness framework cs/scripts/dotnet_harness/adapters/__init__.py new file mode 100644 index 0000000..27a0ba6 --- /dev/null +++ b/Codex/Coding/harness framework cs/scripts/dotnet_harness/adapters/__init__.py @@ -0,0 +1 @@ +""".NET validation command construction.""" diff --git a/Codex/Coding/harness framework cs/scripts/dotnet_harness/adapters/base.py b/Codex/Coding/harness framework cs/scripts/dotnet_harness/adapters/base.py new file mode 100644 index 0000000..20b0086 --- /dev/null +++ b/Codex/Coding/harness framework cs/scripts/dotnet_harness/adapters/base.py @@ -0,0 +1,2 @@ +class AdapterError(RuntimeError): + pass diff --git a/Codex/Coding/harness framework cs/scripts/dotnet_harness/adapters/dotnet.py b/Codex/Coding/harness framework cs/scripts/dotnet_harness/adapters/dotnet.py new file mode 100644 index 0000000..7f00706 --- /dev/null +++ b/Codex/Coding/harness framework cs/scripts/dotnet_harness/adapters/dotnet.py @@ -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)) diff --git a/Codex/Coding/harness framework cs/scripts/dotnet_harness/config.py b/Codex/Coding/harness framework cs/scripts/dotnet_harness/config.py new file mode 100644 index 0000000..9491ee3 --- /dev/null +++ b/Codex/Coding/harness framework cs/scripts/dotnet_harness/config.py @@ -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)) diff --git a/Codex/Coding/harness framework cs/scripts/dotnet_harness/discovery.py b/Codex/Coding/harness framework cs/scripts/dotnet_harness/discovery.py new file mode 100644 index 0000000..3474756 --- /dev/null +++ b/Codex/Coding/harness framework cs/scripts/dotnet_harness/discovery.py @@ -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)) diff --git a/Codex/Coding/harness framework cs/scripts/dotnet_harness/metadata.py b/Codex/Coding/harness framework cs/scripts/dotnet_harness/metadata.py new file mode 100644 index 0000000..c66c711 --- /dev/null +++ b/Codex/Coding/harness framework cs/scripts/dotnet_harness/metadata.py @@ -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 diff --git a/Codex/Coding/harness framework cs/scripts/dotnet_harness/models.py b/Codex/Coding/harness framework cs/scripts/dotnet_harness/models.py new file mode 100644 index 0000000..4128691 --- /dev/null +++ b/Codex/Coding/harness framework cs/scripts/dotnet_harness/models.py @@ -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 diff --git a/Codex/Coding/harness framework cs/scripts/dotnet_harness/paths.py b/Codex/Coding/harness framework cs/scripts/dotnet_harness/paths.py new file mode 100644 index 0000000..781cb48 --- /dev/null +++ b/Codex/Coding/harness framework cs/scripts/dotnet_harness/paths.py @@ -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 diff --git a/Codex/Coding/harness framework cs/scripts/dotnet_harness/process.py b/Codex/Coding/harness framework cs/scripts/dotnet_harness/process.py new file mode 100644 index 0000000..028d366 --- /dev/null +++ b/Codex/Coding/harness framework cs/scripts/dotnet_harness/process.py @@ -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) diff --git a/Codex/Coding/harness framework cs/scripts/dotnet_harness/tdd_policy.py b/Codex/Coding/harness framework cs/scripts/dotnet_harness/tdd_policy.py new file mode 100644 index 0000000..ce41649 --- /dev/null +++ b/Codex/Coding/harness framework cs/scripts/dotnet_harness/tdd_policy.py @@ -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 diff --git a/Codex/Coding/harness framework cs/scripts/dotnet_harness/toolchain.py b/Codex/Coding/harness framework cs/scripts/dotnet_harness/toolchain.py new file mode 100644 index 0000000..5a67abe --- /dev/null +++ b/Codex/Coding/harness framework cs/scripts/dotnet_harness/toolchain.py @@ -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) diff --git a/Codex/Coding/harness framework cs/scripts/execute.py b/Codex/Coding/harness framework cs/scripts/execute.py new file mode 100644 index 0000000..d85fa15 --- /dev/null +++ b/Codex/Coding/harness framework cs/scripts/execute.py @@ -0,0 +1,592 @@ +#!/usr/bin/env python3 +""" +Harness Step Executor — phase 내 step을 순차 실행하고 자가 교정한다. + +Usage: + python scripts/execute.py [--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() diff --git a/Codex/Coding/harness framework cs/scripts/hooks/pre_tool_use.py b/Codex/Coding/harness framework cs/scripts/hooks/pre_tool_use.py new file mode 100644 index 0000000..ebdd120 --- /dev/null +++ b/Codex/Coding/harness framework cs/scripts/hooks/pre_tool_use.py @@ -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.+?)\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()) diff --git a/Codex/Coding/harness framework cs/scripts/hooks/stop_validation.py b/Codex/Coding/harness framework cs/scripts/hooks/stop_validation.py new file mode 100644 index 0000000..727d3c7 --- /dev/null +++ b/Codex/Coding/harness framework cs/scripts/hooks/stop_validation.py @@ -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())