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."