add framework

This commit is contained in:
김경종
2026-09-04 17:00:34 +09:00
parent 0adb9c26a9
commit 1239d5b9f6
18 changed files with 1320 additions and 2 deletions
+4
View File
@@ -0,0 +1,4 @@
#:schema https://developers.openai.com/codex/config-schema.json
[features]
hooks = true
+28
View File
@@ -0,0 +1,28 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "^Bash$",
"hooks": [
{
"type": "command",
"command": "python -c \"import pathlib, runpy, subprocess; root = pathlib.Path(subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], text=True).strip()); runpy.run_path(str(root / '.codex' / 'hooks' / 'pre_commit_checks.py'), run_name='__main__')\"",
"timeout": 600,
"statusMessage": "Running pre-commit checks"
}
]
},
{
"matcher": "^(apply_patch|Edit|Write)$",
"hooks": [
{
"type": "command",
"command": "python -c \"import pathlib, runpy, subprocess; root = pathlib.Path(subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], text=True).strip()); runpy.run_path(str(root / '.codex' / 'hooks' / 'tdd-guard.py'), run_name='__main__')\"",
"timeout": 30,
"statusMessage": "Checking TDD guard"
}
]
}
]
}
}
@@ -0,0 +1,89 @@
import json
import re
import subprocess
import sys
from pathlib import Path
def _repo_root(cwd: Path) -> Path:
try:
root = subprocess.check_output(
["git", "rev-parse", "--show-toplevel"],
cwd=cwd,
text=True,
stderr=subprocess.DEVNULL,
).strip()
except (subprocess.CalledProcessError, FileNotFoundError):
return cwd
return Path(root)
def _is_git_commit(command: str) -> bool:
return re.search(
r"^\s*git(?:\s+(?:-[A-Za-z]\s+\S+|--[A-Za-z0-9-]+(?:=\S+)?))*\s+commit\b",
command,
) is not None
def _deny(reason: str) -> None:
print(
json.dumps(
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}
)
)
def _tail(text: str, limit: int = 1200) -> str:
text = text.strip()
if len(text) <= limit:
return text
return text[-limit:]
def _build_pre_commit_commands(root: Path) -> list[list[str]]:
return [
[sys.executable, "-m", "unittest", "discover", "-s", "scripts", "-p", "test_*.py"],
[sys.executable, "scripts/validate_workspace.py"],
]
def _run_checks(root: Path) -> str | None:
for command in _build_pre_commit_commands(root):
result = subprocess.run(command, cwd=root, capture_output=True, text=True)
if result.returncode != 0:
details = _tail(result.stdout + "\n" + result.stderr)
label = " ".join(command)
if details:
return f"{label} failed:\n{details}"
return f"{label} failed with exit code {result.returncode}."
return None
def main() -> int:
try:
payload = json.load(sys.stdin)
except json.JSONDecodeError:
return 0
command = payload.get("tool_input", {}).get("command", "")
if not isinstance(command, str) or not _is_git_commit(command):
return 0
cwd = Path(payload.get("cwd") or Path.cwd())
root = _repo_root(cwd)
failure = _run_checks(root)
if failure:
_deny(f"PRE-COMMIT CHECKS: {failure}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,183 @@
import json
import subprocess
import sys
from pathlib import Path
SOURCE_SUFFIXES = {".cs"}
TEST_SUFFIXES = {".cs"}
def _repo_root(cwd: Path) -> Path:
try:
root = subprocess.check_output(
["git", "rev-parse", "--show-toplevel"],
cwd=cwd,
text=True,
stderr=subprocess.DEVNULL,
).strip()
except (subprocess.CalledProcessError, FileNotFoundError):
return cwd
return Path(root)
def _extract_patch_paths(command: str) -> list[str]:
prefixes = (
"*** Add File: ",
"*** Update File: ",
"*** Delete File: ",
"*** Move to: ",
)
paths: list[str] = []
for raw_line in command.splitlines():
line = raw_line.strip()
for prefix in prefixes:
if line.startswith(prefix):
paths.append(line[len(prefix) :].strip())
break
return paths
def _touched_paths(payload: dict) -> list[str]:
tool_input = payload.get("tool_input", {})
if not isinstance(tool_input, dict):
return []
file_path = tool_input.get("file_path")
if isinstance(file_path, str) and file_path:
return [file_path]
command = tool_input.get("command")
if isinstance(command, str):
return _extract_patch_paths(command)
return []
def _normalize(path_text: str) -> str:
return path_text.replace("\\", "/").lower()
def _path_segments(path_text: str) -> list[str]:
return [segment for segment in _normalize(path_text).split("/") if segment]
def _is_ignored_path(path_text: str) -> bool:
return any(segment in {"bin", "obj"} for segment in _path_segments(path_text))
def _is_test_path(path_text: str) -> bool:
path = Path(path_text)
if path.suffix.lower() not in TEST_SUFFIXES:
return False
segments = _path_segments(path_text)
name = segments[-1] if segments else ""
directories = segments[:-1]
return (
any(
segment in {"test", "tests"}
or segment.endswith(".test")
or segment.endswith(".tests")
for segment in directories
)
or name.endswith("test.cs")
or name.endswith("tests.cs")
)
def _token(text: str) -> str:
return "".join(ch for ch in text.lower() if ch.isalnum())
def _candidate_test_paths(paths: list[str], cwd: Path, root: Path) -> list[Path]:
candidates: list[Path] = []
for path_text in paths:
resolved = _resolve_path(path_text, cwd)
if _is_test_path(str(resolved)) and not _is_ignored_path(str(resolved)):
candidates.append(resolved)
for candidate in root.rglob("*"):
if (
candidate.is_file()
and candidate.suffix.lower() in TEST_SUFFIXES
and _is_test_path(str(candidate))
and not _is_ignored_path(str(candidate))
):
candidates.append(candidate)
return candidates
def _has_related_test(path: Path, candidate_tests: list[Path]) -> bool:
source_token = _token(path.stem)
for test_path in candidate_tests:
test_token = _token(test_path.stem)
if source_token and source_token in test_token:
return True
return False
def _is_exempt(path_text: str) -> bool:
return _is_test_path(path_text)
def _resolve_path(path_text: str, cwd: Path) -> Path:
path = Path(path_text)
if path.is_absolute():
return path
return (cwd / path).resolve()
def _base_name(path: Path) -> str:
return path.stem
def _guarded_paths(paths: list[str], cwd: Path, root: Path) -> list[str]:
missing_tests: list[str] = []
candidate_tests = _candidate_test_paths(paths, cwd, root)
for path_text in paths:
if _is_exempt(path_text):
continue
path = _resolve_path(path_text, cwd)
if path.suffix.lower() not in SOURCE_SUFFIXES:
continue
if not _has_related_test(path, candidate_tests):
missing_tests.append(_base_name(path))
return missing_tests
def main() -> int:
try:
payload = json.load(sys.stdin)
except json.JSONDecodeError:
return 0
cwd = Path(payload.get("cwd") or Path.cwd())
root = _repo_root(cwd)
missing_tests = _guarded_paths(_touched_paths(payload), cwd, root)
if not missing_tests:
return 0
names = ", ".join(sorted(set(missing_tests)))
print(
json.dumps(
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
"TDD GUARD: missing C# test file for "
f"{names}. Write or add the test first."
),
}
}
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,46 @@
---
name: harness-review
description: "Use when reviewing this C#/.NET Harness repository: local changes, generated phase files, step outputs, implementation diffs, missing tests, dotnet validation readiness, or compliance with AGENTS.md, docs/ARCHITECTURE.md, docs/ADR.md, and Harness acceptance criteria."
---
# Harness Review
## Overview
Use this skill to review Harness work against the repository's persistent rules, architecture docs, C#/.NET SDK constraints, TDD guard policy, and executable verification requirements. Prioritize bugs, regressions, missing tests, and rule violations.
## Review Process
1. Read `/AGENTS.md`, `/docs/ARCHITECTURE.md`, and `/docs/ADR.md`.
2. Inspect the changed files with `git status --short` and `git diff`.
3. Check architecture, C#/.NET stack choices, related C# test coverage, critical rules, and `dotnet` validation readiness.
4. Run Python Harness self-tests when relevant and use `python scripts/validate_workspace.py` for the default build/test review. If a command cannot be run, report that as residual risk.
5. Lead with actionable findings. Keep summaries secondary.
## Checklist
| Item | Question |
| --- | --- |
| Architecture | Does the change follow `docs/ARCHITECTURE.md` ownership boundaries? |
| Stack | Does the change stay within the C#/.NET SDK and `dotnet` CLI decisions documented in `docs/ADR.md`, using the installed `dotnet` without SDK installation or `global.json` pinning? |
| Tests | Are new or changed behaviors covered by Python Harness tests or related C# tests? |
| TDD Guard | Would C# production `.cs` edits be blocked without a related C# test file? |
| Critical Rules | Does the change violate any `AGENTS.md` CRITICAL rule? |
| Build | Does `python scripts/validate_workspace.py` honor `HARNESS_VALIDATION_COMMANDS`, otherwise prefer one root `.sln` and fall back to one root `.csproj`, then run the documented `dotnet` validation sequence? Do the Python self-tests and workspace validation pass? |
When no root `.sln` or `.csproj` exists, treat the C#/.NET guidance messages `No C#/.NET validation target found.` and `Add a root .sln or .csproj, or set HARNESS_VALIDATION_COMMANDS.` with a successful exit as the expected no-project path, not a build-readiness failure.
## Output Format
If there are findings, list them first in severity order with file and line references when possible. Then include this table:
| 항목 | 결과 | 비고 |
| --- | --- | --- |
| 아키텍처 준수 | PASS/FAIL | {상세} |
| 기술 스택 준수 | PASS/FAIL | {상세} |
| 테스트 존재 | PASS/FAIL | {상세} |
| TDD Guard | PASS/FAIL | {상세} |
| CRITICAL 규칙 | PASS/FAIL | {상세} |
| 빌드/검증 가능 | PASS/FAIL | {상세} |
When there are no findings, say that clearly, then mention any commands not run or remaining risk.
@@ -0,0 +1,4 @@
interface:
display_name: "Harness Review"
short_description: "Review Harness changes safely"
default_prompt: "Use $harness-review to review Harness repository changes."
@@ -0,0 +1,132 @@
---
name: harness-workflow
description: "Use when planning or running this C#/.NET Harness framework: reading AGENTS.md and docs/*.md, discussing implementation scope, creating or updating phases/index.json, phases/{task}/index.json, phases/{task}/stepN.md, or invoking scripts/execute.py for staged Codex execution."
---
# Harness Workflow
## Overview
Use this skill to turn a user-approved task into small, self-contained Harness steps that another Codex session can execute reliably. This Harness is dedicated to C#/.NET SDK projects and Python Harness scripts. Keep every step grounded in repository docs, C#/.NET constraints, TDD, and executable acceptance criteria.
## Workflow
1. Read `AGENTS.md` and relevant files under `docs/`, especially `docs/PRD.md`, `docs/ARCHITECTURE.md`, and `docs/ADR.md`.
2. Discuss unresolved product or technical decisions with the user before writing phase files.
3. When the user asks for an implementation plan, draft steps and get approval before creating files.
4. Create or update `phases/index.json`, `phases/{task-name}/index.json`, and one `phases/{task-name}/stepN.md` per step.
5. Run the phase with `python scripts/execute.py {task-name}` when asked to execute it. Use `--push` only when the user asks to push.
## Step Design Rules
- Scope each step to one layer or module. Split steps when multiple modules would otherwise change together.
- Make every step self-contained. Do not rely on prior conversation; include all required context and file paths.
- Force context gathering. Each step must tell Codex which docs and previous outputs to read before editing.
- Specify interfaces and signatures, not full implementations, unless exact code is required for a constraint.
- Put core invariants directly in the step: idempotency, numerical conventions, data integrity, API contracts, or other non-negotiables.
- Use executable acceptance criteria such as `python scripts/validate_workspace.py`, not abstract statements.
- For every C# behavior change, require Codex to write the related C# test file first, run it, and confirm that it fails for the expected reason before changing production code. Name the expected test file or test project.
- Name steps with kebab-case slugs such as `project-setup`, `core-types`, or `solver-validation`.
## Phase Files
Create or update `phases/index.json`:
```json
{
"phases": [
{
"dir": "0-mvp",
"status": "pending"
}
]
}
```
Create `phases/{task-name}/index.json`:
```json
{
"project": "FESA Harness",
"phase": "<task-name>",
"steps": [
{ "step": 0, "name": "project-setup", "status": "pending" },
{ "step": 1, "name": "core-types", "status": "pending" },
{ "step": 2, "name": "validation-path", "status": "pending" }
]
}
```
Rules:
- `project` comes from `AGENTS.md`.
- `phase` matches the task directory name.
- `steps[].step` starts at `0`.
- Initial status is always `pending`.
- Do not add timestamps when creating files. `scripts/execute.py` records `created_at`, `started_at`, `completed_at`, `failed_at`, and `blocked_at`.
## Step Template
```markdown
# Step {N}: {name}
## 읽어야 할 파일
먼저 아래 파일들을 읽고 프로젝트의 아키텍처와 설계 의도를 파악하라:
- `/AGENTS.md`
- `/docs/ARCHITECTURE.md`
- `/docs/ADR.md`
- {previously created or modified files}
이전 step에서 만들어진 코드를 꼼꼼히 읽고, 설계 의도를 이해한 뒤 작업하라.
## 작업
{Concrete instructions with file paths, interfaces, signatures, and rules.}
## Tests To Write First
- {For a C# behavior change, name the exact C# test file and behavior to add before implementation. Require running it first and confirming the expected failure. For a Harness script change, name the exact Python test.}
## Acceptance Criteria
```bash
python -m unittest discover -s scripts -p "test_*.py"
python scripts/validate_workspace.py
```
## 검증 절차
1. 위 AC 커맨드를 실행한다.
2. 아키텍처 체크리스트를 확인한다:
- ARCHITECTURE.md 디렉토리 구조를 따르는가?
- ADR 기술 스택을 벗어나지 않았는가?
- AGENTS.md CRITICAL 규칙을 위반하지 않았는가?
- PATH에 설치된 `dotnet`을 사용하며 SDK 설치나 `global.json` 고정을 추가하지 않았는가?
- `HARNESS_VALIDATION_COMMANDS`가 있으면 우선하고, 그렇지 않으면 루트의 단일 `.sln`을 우선하며 solution이 없을 때만 루트의 단일 `.csproj`를 선택하는가?
- 동일 우선순위의 루트 target이 여러 개일 때 임의 선택하지 않는가?
- C# production `.cs` 변경에는 먼저 실패를 확인한 관련 C# test가 존재하는가?
3. 결과에 따라 `phases/{task-name}/index.json`의 해당 step을 업데이트한다:
- 성공: `"status": "completed"`, `"summary": "산출물 한 줄 요약"`
- 3회 수정 시도 후 실패: `"status": "error"`, `"error_message": "구체적 에러 내용"`
- 사용자 개입 필요: `"status": "blocked"`, `"blocked_reason": "구체적 사유"` 후 중단
## 금지사항
- 다른 언어 또는 npm fallback을 추가하지 마라. Reason: 이 Harness는 C#/.NET SDK 전용이며 언어별 fallback은 validation과 TDD 계약을 흐린다.
- 기존 테스트를 깨뜨리지 마라.
```
## Execution And Recovery
Run:
```bash
python scripts/execute.py {task-name}
python scripts/execute.py {task-name} --push
```
`scripts/execute.py` creates or checks out `feat-{task-name}`, injects `AGENTS.md` and `docs/*.md` into each prompt, carries completed step summaries forward, retries failed steps up to three times, separates code and metadata commits, and records timestamps.
If a step is `error`, set it back to `pending` and remove `error_message` after fixing the cause. If a step is `blocked`, resolve `blocked_reason`, set it back to `pending`, remove `blocked_reason`, and rerun.
@@ -0,0 +1,4 @@
interface:
display_name: "Harness Workflow"
short_description: "Plan staged Harness workflow steps"
default_prompt: "Use $harness-workflow to plan Harness phases and step files."
+16
View File
@@ -0,0 +1,16 @@
.vs/
[Bb]in/
[Oo]bj/
TestResults/
*.user
*.suo
*.nupkg
*.snupkg
coverage/
coverage.*
__pycache__/
*.pyc
# phase execution outputs
phases/**/phase*-output.json
phases/**/step*-output.json
+48
View File
@@ -0,0 +1,48 @@
# Project: FESA Harness
## 기술 스택
- C#/.NET SDK
- `dotnet` CLI
- Harness scripts in Python 3
## 아키텍처 규칙
- CRITICAL: 기본 검증 경로는 `python scripts/validate_workspace.py`이다.
- CRITICAL: `HARNESS_VALIDATION_COMMANDS`가 설정되어 있으면 자동 대상 탐색보다 우선한다.
- CRITICAL: 자동 탐색은 루트의 단일 `.sln`을 우선하고, 없으면 루트의 단일 SDK-style `.csproj`를 사용한다. 동일 우선순위 대상이 여러 개면 임의 선택하지 않고 오류로 중단한다.
- CRITICAL: 기본 검증은 선택한 target에 대해 `dotnet restore`, `dotnet build --configuration Debug --no-restore`, `dotnet test --configuration Debug --no-build --no-restore` 순서로 실행한다.
- CRITICAL: PATH에 설치된 `dotnet`을 사용하며 Harness는 .NET SDK 버전이나 `global.json`을 생성하거나 고정하지 않는다.
- CRITICAL: 새 기능 또는 동작 변경은 테스트를 먼저 작성하고 실패를 확인한 뒤 구현한다.
- CRITICAL: Abaqus reference artifact나 solver 코드 복원은 명시적으로 요청된 phase에서만 수행한다.
- 다중 project 저장소는 루트 solution에 모든 build/test 대상을 포함해야 한다. 단일 `.csproj` fallback은 단일-project 저장소에서만 사용한다.
- solution/project가 아직 없으면 안내 메시지를 출력하고 성공 종료하여 Harness 자체 테스트를 허용한다.
- Harness runner는 `scripts/execute.py`에 둔다.
- Codex hook 정책은 `.codex/hooks/`에 둔다.
- Harness planning/review instructions are stored in `.codex/skills/`.
- Generated phase execution outputs remain ignored under `phases/**/step*-output.json`.
## 개발 프로세스
- TDD를 기본으로 한다. C# production `.cs` file을 바꿀 때는 관련 C# test file이 있어야 한다.
- 커밋 전 hook은 Harness Python self-test와 workspace validation을 실행해야 한다.
- 커밋 메시지는 conventional commits 형식을 따른다: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`.
- 계획이 필요한 장기 작업은 Harness phase로 나누고, 각 step은 독립 실행 가능해야 한다.
## 명령어
```bash
python -m unittest discover -s scripts -p "test_*.py"
python scripts/validate_workspace.py
python scripts/execute.py <phase-dir>
python scripts/execute.py <phase-dir> --push
```
## .NET 검증 기본값
- SDK resolution: PATH의 `dotnet` 사용; SDK 버전 및 `global.json` 생성·고정 없음
- Target selection: 루트의 단일 `.sln` 우선, 없으면 루트의 단일 SDK-style `.csproj`
- Ambiguity: 동일 우선순위 대상이 여러 개면 오류
- Configuration: `Debug`
- Commands:
1. `dotnet restore <target>`
2. `dotnet build <target> --configuration Debug --no-restore`
3. `dotnet test <target> --configuration Debug --no-build --no-restore`
Override variable:
- `HARNESS_VALIDATION_COMMANDS`
+52
View File
@@ -0,0 +1,52 @@
# Architecture Decision Records
## 철학
Harness는 현재 프로젝트의 실제 기술 스택을 반영해야 한다. 이 저장소는 C#/.NET SDK 전용 정책을 사용하며 다른 언어의 fallback이나 언어별 profile/registry를 두지 않는다.
---
### ADR-001: C#/.NET 전용 Harness
**결정**: Harness scaffold는 C#/.NET SDK와 `dotnet` CLI 전용으로 운영하고, Harness script에는 Python 3을 사용한다. 언어별 profile이나 registry 및 다른 언어 fallback은 두지 않는다.
**이유**: 단일 기술 스택을 명시해야 validation, TDD guard, acceptance criteria가 실제 FESA 개발 환경과 일치한다.
**트레이드오프**: 다른 언어 프로젝트에는 이 Harness를 그대로 재사용할 수 없다.
### ADR-002: 호스트의 .NET SDK 사용
**결정**: PATH에 설치된 `dotnet`을 사용한다. Harness는 .NET SDK 버전을 선택·설치하지 않으며 `global.json`을 생성하거나 고정하지 않는다.
**이유**: SDK 수명주기와 설치 정책은 개발 환경이 소유하고, Harness는 저장소 검증만 담당한다.
**트레이드오프**: 실행 결과는 호스트에서 해석되는 SDK에 의존하므로 필요한 SDK 제공은 환경 구성의 책임이다.
### ADR-003: 결정적인 루트 target 선택
**결정**: 자동 탐색은 루트의 단일 `.sln`을 우선한다. solution이 없을 때만 루트의 단일 SDK-style `.csproj`를 단일-project fallback으로 사용한다. 동일 우선순위 대상이 여러 개면 임의 선택하지 않고 오류로 중단한다. solution/project가 없으면 안내 후 성공 종료한다.
**이유**: solution은 다중 project의 build/test 경계를 명시한다. 모호한 대상을 자동 선택하면 일부 project나 test가 검증에서 누락될 수 있고, 아직 application이 없는 Harness scaffold는 자체 테스트가 가능해야 한다.
**트레이드오프**: 다중 project 저장소는 루트 solution에 모든 build/test 대상을 포함해야 한다. 루트 solution이 없고 `.csproj`가 여러 개인 저장소는 명시적으로 구조를 정리해야 한다.
### ADR-004: 표준 workspace validation 순서
**결정**: 기본 진입점은 `python scripts/validate_workspace.py`이다. `HARNESS_VALIDATION_COMMANDS`가 설정되어 있으면 자동 target 탐색보다 우선하여 지정된 명령을 실행한다. 그렇지 않으면 선택한 target에 대해 다음 순서로 실행한다.
1. `dotnet restore <target>`
2. `dotnet build <target> --configuration Debug --no-restore`
3. `dotnet test <target> --configuration Debug --no-build --no-restore`
**이유**: restore, build, test 단계를 분리하고 후속 단계의 암묵적 restore/build를 차단하면 실패 지점과 검증 범위가 명확해진다.
**트레이드오프**: 특수한 저장소는 `HARNESS_VALIDATION_COMMANDS`로 전체 검증 절차를 명시해야 한다.
### ADR-005: 엄격한 C# TDD Guard
**결정**: C# production `.cs` file 변경은 관련 C# test file이 없으면 차단한다.
**이유**: Harness의 핵심 목적은 agent가 검증 없는 production 변경을 만들지 않도록 하는 것이다.
**트레이드오프**: 초기 scaffolding 작업에서 guard가 엄격하게 느껴질 수 있다. 문서, project 설정, Harness metadata는 guard 대상에서 제외한다.
### ADR-006: Harness 자체 테스트 우선
**결정**: commit hook은 먼저 Python Harness self-test를 실행한 뒤 `python scripts/validate_workspace.py`를 실행한다.
**이유**: solution/project가 아직 없더라도 Harness 자체는 검증 가능해야 이후 source 및 project 구성을 안전하게 진행할 수 있다.
**트레이드오프**: commit 시간이 조금 늘어나지만 hook/validation regression을 빠르게 발견한다.
@@ -0,0 +1,68 @@
# 아키텍처
## 목표
이 저장소의 현재 책임은 C#/.NET SDK 프로젝트를 위한 Codex Harness scaffold를 제공하는 것이다. Harness는 phase execution, edit guard, commit validation, workspace validation을 분리해서 관리한다.
Application과 test target은 C#/.NET SDK를 사용하고 Harness script는 Python 3으로 실행한다. Harness는 PATH에 설치된 `dotnet`을 그대로 사용한다. .NET SDK 버전을 선택하거나 설치하지 않으며 `global.json`을 생성하거나 고정하지 않는다.
## 디렉토리 구조
```text
.codex/
├── hooks/ # Codex hook scripts
└── skills/ # Harness planning/review instructions
docs/ # Project and Harness guidance
scripts/
├── execute.py # Phase step executor
├── validate_workspace.py
└── test_*.py # Harness self-tests
phases/ # Optional generated phase plans
```
## 데이터 흐름
```text
User-approved task
-> Harness phase files under phases/
-> scripts/execute.py injects AGENTS.md and docs/*.md
-> Codex executes one step at a time
-> step updates phases/{phase}/index.json
-> validation runs through scripts/validate_workspace.py
```
## Hook 흐름
```text
apply_patch/Edit/Write
-> .codex/hooks/tdd-guard.py
-> C# production .cs changes require related C# test files
git commit command
-> .codex/hooks/pre_commit_checks.py
-> Python Harness self-tests
-> scripts/validate_workspace.py
```
## Validation 흐름
```text
python scripts/validate_workspace.py
-> HARNESS_VALIDATION_COMMANDS set?
-> yes: run the exact override commands; skip automatic target discovery
-> no: inspect repository-root targets
Exactly one root .sln exists
-> select that solution, even when root .csproj files also exist
No root .sln exists and exactly one root SDK-style .csproj exists
-> select that project as the single-project fallback
Multiple targets exist at the same active priority
-> report ambiguity and exit with an error; never choose arbitrarily
No root solution or SDK-style project exists
-> print guidance and exit successfully
Selected target
-> dotnet restore <target>
-> dotnet build <target> --configuration Debug --no-restore
-> dotnet test <target> --configuration Debug --no-build --no-restore
```
다중 project 저장소는 루트 solution에 모든 build/test 대상을 포함해야 한다. 루트의 단일 SDK-style `.csproj` fallback은 단일-project 저장소만을 위한 경로다.
+32
View File
@@ -0,0 +1,32 @@
# PRD: C#/.NET Harness
## 목표
Codex Harness가 C#/.NET SDK 프로젝트에서 phase planning, TDD guard, commit validation, workspace validation을 일관되게 수행하게 한다.
## 기술 스택
- C#/.NET SDK
- `dotnet` CLI
- Harness scripts in Python 3
## 사용자
- `dotnet` CLI 기반 C#/.NET 개발자
- Harness phase를 작성하고 실행하는 Codex agent
- Harness 결과를 검토하는 reviewer
## 핵심 기능
1. 기본 진입점은 `python scripts/validate_workspace.py`이며, `HARNESS_VALIDATION_COMMANDS`가 설정되어 있으면 자동 탐색보다 우선한다.
2. 자동 탐색은 루트의 단일 `.sln`을 우선하고, solution이 없으면 루트의 단일 SDK-style `.csproj`를 선택한다. 동일 우선순위 대상이 여러 개면 임의 선택하지 않고 오류로 중단한다.
3. 선택한 target에 대해 `dotnet restore <target>`, `dotnet build <target> --configuration Debug --no-restore`, `dotnet test <target> --configuration Debug --no-build --no-restore` 순서로 검증한다.
4. PATH에 설치된 `dotnet`을 사용하며 Harness는 .NET SDK 버전이나 `global.json`을 생성하거나 고정하지 않는다.
5. C# production `.cs` 변경에 관련 C# test file을 요구하는 엄격한 TDD guard를 제공한다.
6. 다중 project 저장소는 루트 solution에 모든 build/test 대상을 포함해야 하며, 단일 `.csproj` fallback은 단일-project 저장소에서만 사용한다.
7. solution/project가 아직 없으면 안내 후 성공 종료하여 Harness 자체 테스트를 허용한다.
8. commit hook은 Python Harness self-test를 먼저 실행한 뒤 workspace validation을 실행한다.
## 제외 사항
- 언어별 profile 또는 registry
- 다른 언어 validation fallback
- .NET SDK 버전 선택, 설치 또는 `global.json` 생성
- C# sample application 또는 project 생성
- 이전 FESA solver source tree 복원
- Abaqus reference artifact 생성 또는 solver reference 비교 구현
+76
View File
@@ -0,0 +1,76 @@
# UI 디자인 가이드
## 디자인 원칙
1. {원칙 1 — 예: "도구처럼 보여야 한다. 마케팅 페이지가 아니라 매일 쓰는 대시보드."}
2. {원칙 2}
3. {원칙 3}
## AI 슬롭 안티패턴 — 하지 마라
| 금지 사항 | 이유 |
|-----------|------|
| backdrop-filter: blur() | glass morphism은 AI 템플릿의 가장 흔한 징후 |
| gradient-text (배경 그라데이션 텍스트) | AI가 만든 SaaS 랜딩의 1번 특징 |
| "Powered by AI" 배지 | 기능이 아니라 장식. 사용자에게 가치 없음 |
| box-shadow 글로우 애니메이션 | 네온 글로우 = AI 슬롭 |
| 보라/인디고 브랜드 색상 | "AI = 보라색" 클리셰 |
| 모든 카드에 동일한 rounded-2xl | 균일한 둥근 모서리는 템플릿 느낌 |
| 배경 gradient orb (blur-3xl 원형) | 모든 AI 랜딩 페이지에 있는 장식 |
## 색상
### 배경
| 용도 | 값 |
|------|------|
| 페이지 | {예: #0a0a0a} |
| 카드 | {예: #141414} |
### 텍스트
| 용도 | 값 |
|------|------|
| 주 텍스트 | {예: text-white} |
| 본문 | {예: text-neutral-300} |
| 보조 | {예: text-neutral-400} |
| 비활성 | {예: text-neutral-500} |
### 데이터/시맨틱 색상
| 용도 | 값 |
|------|------|
| {긍정/성공} | {예: #22c55e} |
| {부정/에러} | {예: #ef4444} |
| {중립/기본} | {예: #525252} |
## 컴포넌트
### 카드
```
{예: rounded-lg bg-[#141414] border border-neutral-800 p-6}
```
### 버튼
```
Primary: {예: rounded-lg bg-white text-black hover:bg-neutral-200}
Text: {예: text-neutral-500 hover:text-neutral-300}
```
### 입력 필드
```
{예: rounded-lg bg-neutral-900 border border-neutral-800 px-4 py-3}
```
## 레이아웃
- 전체 너비: {예: max-w-5xl}
- 정렬: {예: 좌측 정렬 기본. 중앙 정렬 금지}
- 간격: {예: gap-3~4, 섹션 간 space-y-8}
## 타이포그래피
| 용도 | 스타일 |
|------|--------|
| 페이지 제목 | {예: text-4xl font-semibold text-white} |
| 카드 제목 | {예: text-sm font-medium text-neutral-400} |
| 본문 | {예: text-sm text-neutral-300 leading-relaxed} |
## 애니메이션
- {허용할 애니메이션만 나열. 예: fade-in (0.4s), slide-up (0.5s)}
- {그 외 모든 애니메이션 금지}
## 아이콘
- {예: SVG 인라인, strokeWidth 1.5}
- {예: 아이콘 컨테이너(둥근 배경 박스)로 감싸지 않는다}
+417
View File
@@ -0,0 +1,417 @@
#!/usr/bin/env python3
"""
Harness Step Executor — phase 내 step을 순차 실행하고 자가 교정한다.
Usage:
python scripts/execute.py <phase-dir> [--push]
"""
import argparse
import contextlib
import json
import os
import subprocess
import sys
import threading
import time
import types
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Optional
ROOT = Path(__file__).resolve().parent.parent
@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(f"## 프로젝트 규칙 (AGENTS.md)\n\n{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:
commit_example = self.FEAT_MSG.format(
phase=self._phase_name, num="N", name="<step-name>"
)
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. 모든 변경사항을 커밋하라:\n"
f" {commit_example}\n\n---\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")
result = subprocess.run(
["codex", "exec", "--dangerously-bypass-approvals-and-sandbox", "--json", prompt],
cwd=self._root, capture_output=True, text=True, timeout=1800,
)
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"
with open(out_path, "w", encoding="utf-8") as f:
json.dump(output, f, indent=2, ensure_ascii=False)
return output
# --- 헤더 & 검증 ---
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:
self._invoke_codex(step, preamble)
elapsed = int(pi.elapsed)
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)
err_msg = next(
(s.get("error_message", "Step did not update status") for s in index["steps"] if s["step"] == step_num),
"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()
StepExecutor(args.phase_dir, auto_push=args.push).run()
if __name__ == "__main__":
main()
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Run C#/.NET Harness validation commands."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
class ValidationConfigurationError(Exception):
"""Raised when automatic target discovery is ambiguous."""
def load_env_commands() -> list[str]:
raw = os.environ.get("HARNESS_VALIDATION_COMMANDS", "")
return [line for line in raw.splitlines() if line.strip()]
def _root_files_with_suffix(root: Path, suffix: str) -> list[Path]:
return sorted(
(
path.resolve()
for path in root.iterdir()
if path.is_file() and path.suffix.lower() == suffix
),
key=lambda path: path.name.casefold(),
)
def _select_single_target(candidates: list[Path], candidate_kind: str) -> Path | None:
if len(candidates) > 1:
names = ", ".join(path.name for path in candidates)
raise ValidationConfigurationError(
f"Multiple {candidate_kind} targets found at repository root: {names}. "
"Keep exactly one target or set HARNESS_VALIDATION_COMMANDS "
"to define validation explicitly."
)
return candidates[0] if candidates else None
def discover_target(root: Path) -> Path | None:
solutions = _root_files_with_suffix(root, ".sln")
if solutions:
return _select_single_target(solutions, "solution (.sln)")
projects = _root_files_with_suffix(root, ".csproj")
return _select_single_target(projects, "project (.csproj)")
def load_dotnet_commands(root: Path) -> list[str]:
target = discover_target(root)
if target is None:
return []
quoted_target = f'"{target}"'
return [
f"dotnet restore {quoted_target}",
f"dotnet build {quoted_target} --configuration Debug --no-restore",
f"dotnet test {quoted_target} --configuration Debug --no-build --no-restore",
]
def discover_commands(root: Path) -> list[str]:
env_commands = load_env_commands()
if env_commands:
return env_commands
return load_dotnet_commands(root)
def run_command(command: str, root: Path) -> subprocess.CompletedProcess:
return subprocess.run(
command,
cwd=root,
shell=True,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
def emit_stream(prefix: str, content: str, *, stream) -> None:
text = (content or "").strip()
if not text:
return
print(prefix, file=stream)
print(text, file=stream)
def main() -> int:
root = Path(__file__).resolve().parent.parent
try:
commands = discover_commands(root)
except ValidationConfigurationError as error:
print(f"Validation configuration error: {error}", file=sys.stderr)
return 2
if not commands:
print("No C#/.NET validation target found.")
print("Add a root .sln or .csproj, or set HARNESS_VALIDATION_COMMANDS.")
return 0
for command in commands:
print(f"$ {command}")
result = run_command(command, root)
emit_stream("[stdout]", result.stdout, stream=sys.stdout)
emit_stream("[stderr]", result.stderr, stream=sys.stderr)
if result.returncode != 0:
print(f"Validation failed: {command}", file=sys.stderr)
return result.returncode
print("Validation succeeded.")
return 0
if __name__ == "__main__":
raise SystemExit(main())