add framework
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
#:schema https://developers.openai.com/codex/config-schema.json
|
||||
|
||||
[features]
|
||||
hooks = true
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -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 저장소만을 위한 경로다.
|
||||
@@ -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 비교 구현
|
||||
@@ -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}
|
||||
- {예: 아이콘 컨테이너(둥근 배경 박스)로 감싸지 않는다}
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user