modify documents

This commit is contained in:
NINI
2026-04-28 01:30:16 +09:00
parent 246d164827
commit 949e0ab13c
137 changed files with 5172 additions and 1154 deletions
@@ -0,0 +1,131 @@
---
name: "document-harness"
description: "Use when creating, running, or updating the staged Markdown document-writing Harness from docs/PRD.md through research notes, drafts, feedback gates, and final documents."
---
# Document Harness Skill
Use this skill to turn `docs/PRD.md` into researched, reviewable, and feedback-driven Markdown documents.
## Operating Rules
1. Read `AGENTS.md`, `docs/PRD.md`, `docs/ARCHITECTURE.md`, `docs/ADR.md`, and `docs/UI_GUIDE.md` before planning document work.
2. Treat `docs/PRD.md` as the single source of requirements.
3. If PRD purpose, target reader, final deliverables, scope, or key questions are materially empty, stop and ask the user to complete PRD first.
4. Use `docs/ResearchNote.md` as the evidence ledger before drafting externally factual content.
5. Store review drafts under `drafts/` and final deliverables under `final/`.
6. Preserve `docs/DraftFeedback.md` and `docs/FinalFeedback.md`; never delete user feedback.
7. Run `python scripts/validate_docs.py` before reporting completion.
## Staged Workflow
### 1. PRD Intake
Read `docs/PRD.md` and identify:
- document purpose
- target readers
- final deliverables
- required outline
- important keywords
- key questions
- scope boundaries
- tone and style constraints
- research requirements
### 2. Rule Synthesis
Update only the relevant project-specific guidance in `AGENTS.md`.
Include:
- document purpose and target readers
- final deliverables
- tone and style rules
- citation and verification standards
- draft and final feedback process
Keep the generic Codex configuration and repository workflow concise.
### 3. Research Note
Research PRD keywords and key questions. Prefer official, academic, government, institutional, or other primary sources.
Write `docs/ResearchNote.md` with:
- search date
- search terms
- source URLs
- source quality notes
- core findings
- conflicting claims
- unresolved questions
- intended document usage
Use `doc_researcher` or `evidence_checker` agents when the user or current phase explicitly asks for subagent work.
### 4. Draft Documents
Create all PRD deliverables under `drafts/`.
Drafts must:
- answer the PRD key questions
- stay inside PRD scope
- use the requested tone
- link factual claims to `docs/ResearchNote.md`
- mark weak or missing evidence
After drafting, request user review in `docs/DraftFeedback.md`.
### 5. Draft Feedback Gate
If `docs/DraftFeedback.md` has no actionable user feedback or approval, mark the phase step as `blocked` with a clear `blocked_reason`.
If feedback exists, summarize it before revising.
### 6. Final Documents
Create final deliverables under `final/`. Do not overwrite `drafts/`.
Final documents must reflect:
- PRD requirements
- ResearchNote evidence
- DraftFeedback requests
- UI guide style rules
After finalizing, request user review or approval in `docs/FinalFeedback.md`.
### 7. Final Feedback Gate
If `docs/FinalFeedback.md` does not contain approval or actionable next feedback, mark the phase step as `blocked`.
If approval exists, mark the phase completed.
## Phase Files
When creating a new phase, use `references/phase-templates.md`.
Each step must include:
- files to read
- exact task
- acceptance criteria
- validation procedure
- status update instructions
- concrete forbidden actions
## Validation
Always run:
```bash
python scripts/validate_docs.py
```
For executor changes, also run:
```bash
python -m pytest scripts/test_execute.py
```
@@ -0,0 +1,85 @@
# Document Harness Phase Templates
## Top-Level Phase Index
Create or update `phases/index.json`.
```json
{
"phases": [
{
"dir": "0-document",
"status": "pending"
}
]
}
```
## Task Index
Create `phases/{task-name}/index.json`.
```json
{
"project": "<문서 프로젝트명>",
"phase": "<task-name>",
"steps": [
{ "step": 0, "name": "rule-synthesis", "status": "pending" },
{ "step": 1, "name": "research-note", "status": "pending" },
{ "step": 2, "name": "draft-documents", "status": "pending" },
{ "step": 3, "name": "draft-feedback-gate", "status": "pending" },
{ "step": 4, "name": "final-documents", "status": "pending" },
{ "step": 5, "name": "final-feedback-gate", "status": "pending" }
]
}
```
## Step File
Create `phases/{task-name}/step{N}.md`.
```markdown
# Step {N}: {이름}
## 읽어야 할 파일
먼저 아래 파일들을 읽고 문서 목적과 작성 기준을 파악하라:
- `/AGENTS.md`
- `/docs/PRD.md`
- `/docs/ARCHITECTURE.md`
- `/docs/ADR.md`
- `/docs/UI_GUIDE.md`
- {이전 step에서 생성/수정된 파일 경로}
## 작업
{구체적인 문서 작성 또는 검토 지시. 파일 경로, 산출물 이름, 반영해야 할 PRD 항목, 출처 기준을 포함한다.}
## Acceptance Criteria
```bash
python scripts/validate_docs.py
```
## 검증 절차
1. 위 AC 커맨드를 실행한다.
2. 문서 체크리스트를 확인한다:
- `docs/PRD.md`의 목적, 독자, 범위를 벗어나지 않았는가?
- 외부 사실은 `docs/ResearchNote.md`의 출처와 연결되는가?
- 초안은 `drafts/`, 최종본은 `final/`에 분리되었는가?
- 사용자 피드백 파일을 삭제하거나 덮어쓰지 않았는가?
3. 결과에 따라 `phases/{task-name}/index.json`의 해당 step을 업데이트한다:
- 성공 -> `"status": "completed"`, `"summary": "산출물 한 줄 요약"`
- 수정 3회 시도 후에도 실패 -> `"status": "error"`, `"error_message": "구체적 에러 내용"`
- 사용자 개입 필요 -> `"status": "blocked"`, `"blocked_reason": "구체적 요청 사항"` 후 즉시 중단
## 금지사항
- PRD에 없는 문서 목표를 추가하지 마라. 이유: 사용자 의도가 흐려진다.
- 출처 없는 외부 사실을 최종 문서에 단정하지 마라. 이유: 검증 가능성이 사라진다.
- 초안 파일을 최종본으로 덮어쓰지 마라. 이유: 피드백 전후 변경 추적이 어렵다.
- 사용자 피드백 파일을 삭제하지 마라. 이유: 의사결정 기록이 사라진다.
- 직접 `git commit`하지 마라. 이유: `scripts/execute.py`가 step 완료 후 커밋을 관리한다.
```
@@ -0,0 +1,45 @@
---
name: "document-review"
description: "Use when reviewing Markdown document changes for PRD alignment, source traceability, feedback coverage, structure, and final delivery readiness."
---
# Document Review Skill
Use this skill to review changed Markdown files in the Codex Markdown Document Harness.
## Read First
- `AGENTS.md`
- `docs/PRD.md`
- `docs/ResearchNote.md`
- `docs/DraftFeedback.md`
- `docs/FinalFeedback.md`
- `docs/ARCHITECTURE.md`
- `docs/ADR.md`
- `docs/UI_GUIDE.md`
## Review Checklist
1. PRD alignment: purpose, target reader, deliverables, scope, and tone match `docs/PRD.md`.
2. Source traceability: external facts, dates, statistics, claims, and quotations connect to `docs/ResearchNote.md`.
3. Structure: heading hierarchy, section order, and file names match the intended deliverables.
4. Feedback coverage: `docs/DraftFeedback.md` or `docs/FinalFeedback.md` requests are addressed.
5. Draft/final separation: drafts live under `drafts/`; final deliverables live under `final/`.
6. Style quality: avoid generic AI prose, unsupported superlatives, repetition, and vague claims.
7. Validation: `python scripts/validate_docs.py` passes.
## Output Format
Lead with findings. Use this table when a full checklist result is useful:
| 항목 | 결과 | 비고 |
|------|------|------|
| PRD 정합성 | PASS/FAIL | {상세} |
| 출처 추적 | PASS/FAIL | {상세} |
| 문서 구조 | PASS/FAIL | {상세} |
| 피드백 반영 | PASS/FAIL | {상세} |
| 초안/최종본 분리 | PASS/FAIL | {상세} |
| 문체 품질 | PASS/FAIL | {상세} |
| 검증 가능성 | PASS/FAIL | {상세} |
If there are issues, include concrete file paths and suggested fixes.
@@ -0,0 +1,16 @@
name = "doc_drafter"
description = "Turns PRD requirements and ResearchNote evidence into reviewable Markdown drafts."
nickname_candidates = ["Drafter", "Draft"]
model_reasoning_effort = "high"
developer_instructions = """
You are the drafting specialist for the Codex Markdown Document Harness.
Responsibilities:
- Read AGENTS.md, docs/PRD.md, docs/ResearchNote.md, and docs/UI_GUIDE.md before drafting.
- Create draft documents only under drafts/.
- Keep the document goal, audience, scope, and tone aligned with docs/PRD.md.
- Tie external claims to docs/ResearchNote.md sources.
- Preserve user feedback files and do not overwrite final/ documents.
- If a PRD requirement is ambiguous, mark the ambiguity in the draft or report it to the parent agent.
"""
@@ -0,0 +1,15 @@
name = "doc_researcher"
description = "Researches PRD keywords, gathers trustworthy sources, and maintains docs/ResearchNote.md."
nickname_candidates = ["Researcher", "Research"]
model_reasoning_effort = "high"
developer_instructions = """
You are the research specialist for the Codex Markdown Document Harness.
Responsibilities:
- Read AGENTS.md, docs/PRD.md, and docs/UI_GUIDE.md before researching.
- Prefer primary sources: official documentation, government or institutional publications, academic papers, and original company materials.
- Record search date, search terms, source URLs, core claims, conflicts, and where each source should be reflected in docs/ResearchNote.md.
- Mark uncertain claims as 확인 필요 instead of presenting them as facts.
- Do not write final prose in final/. Your primary output is docs/ResearchNote.md and concise research notes for the parent agent.
"""
@@ -0,0 +1,14 @@
name = "doc_reviewer"
description = "Reviews Markdown documents for PRD alignment, evidence quality, structure, and feedback coverage."
nickname_candidates = ["Reviewer", "Review"]
model_reasoning_effort = "medium"
developer_instructions = """
You are the review specialist for the Codex Markdown Document Harness.
Responsibilities:
- Review changed Markdown files against AGENTS.md, docs/PRD.md, docs/ResearchNote.md, docs/DraftFeedback.md, docs/FinalFeedback.md, and docs/UI_GUIDE.md.
- Lead with concrete issues, ordered by severity, with file paths and line references when possible.
- Check PRD alignment, source traceability, draft/final separation, feedback preservation, and Markdown structure.
- Do not rewrite documents unless the parent agent explicitly asks you to make edits.
"""
@@ -0,0 +1,14 @@
name = "evidence_checker"
description = "Checks whether factual claims in drafts and final documents are supported by ResearchNote sources."
nickname_candidates = ["Evidence", "Checker"]
model_reasoning_effort = "medium"
developer_instructions = """
You are the evidence checking specialist for the Codex Markdown Document Harness.
Responsibilities:
- Compare drafts/ and final/ documents with docs/ResearchNote.md.
- Identify unsupported statistics, dates, legal or policy claims, product/version claims, and quotations.
- Report missing, weak, stale, or conflicting evidence.
- Prefer concise claim-to-source mapping over broad style feedback.
"""
+10
View File
@@ -0,0 +1,10 @@
web_search = "live"
[features]
codex_hooks = true
multi_agent = true
[agents]
max_threads = 4
max_depth = 1
job_max_runtime_seconds = 1800
+30
View File
@@ -0,0 +1,30 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|shell_command",
"hooks": [
{
"type": "command",
"command": "python .codex/hooks/pre_tool_guard.py",
"timeout": 10,
"statusMessage": "Checking shell command safety"
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python .codex/hooks/stop_validate.py",
"timeout": 60,
"statusMessage": "Validating document harness files"
}
]
}
]
}
}
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Codex PreToolUse guard for obviously destructive shell commands."""
import json
import re
import sys
from typing import Any
DANGEROUS_PATTERNS = [
(r"\brm\s+-rf\b", "Recursive force deletion is blocked by the document harness."),
(
r"\bRemove-Item\b(?=.*\b-Recurse\b|\s-r\b)(?=.*\b-Force\b|\s-f\b)",
"PowerShell recursive force deletion is blocked by the document harness.",
),
(r"\bgit\s+reset\s+--hard\b", "Hard reset is blocked because it can discard user work."),
(r"\bgit\s+push\b.*\s--force(?:-with-lease)?\b", "Force push is blocked by the document harness."),
(r"\bDROP\s+TABLE\b", "Destructive database commands are blocked by the document harness."),
]
def iter_strings(value: Any):
if isinstance(value, str):
yield value
elif isinstance(value, dict):
for key, item in value.items():
yield str(key)
yield from iter_strings(item)
elif isinstance(value, list):
for item in value:
yield from iter_strings(item)
def deny(reason: str) -> None:
payload = {
"hookSpecificOutput": {
"permissionDecision": "deny",
"permissionDecisionReason": reason,
},
"decision": "block",
"reason": reason,
}
print(json.dumps(payload, ensure_ascii=False))
def main() -> int:
raw = sys.stdin.read()
haystack = raw
try:
data = json.loads(raw) if raw.strip() else {}
except json.JSONDecodeError:
data = {}
if data:
haystack += "\n" + "\n".join(iter_strings(data))
for pattern, reason in DANGEROUS_PATTERNS:
if re.search(pattern, haystack, flags=re.IGNORECASE | re.DOTALL):
deny(reason)
return 0
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Codex Stop hook that asks the agent to continue when template validation fails."""
import json
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def main() -> int:
result = subprocess.run(
[sys.executable, "scripts/validate_docs.py"],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if result.returncode == 0:
return 0
details = "\n".join(part for part in [result.stdout.strip(), result.stderr.strip()] if part)
payload = {
"decision": "block",
"reason": (
"Document harness validation failed. Continue the turn, fix the listed "
f"issues, and run `python scripts/validate_docs.py` again.\n\n{details}"
),
}
print(json.dumps(payload, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
+14
View File
@@ -0,0 +1,14 @@
node_modules/
.next/
out/
next-env.d.ts
tsconfig.tsbuildinfo
# Python/test cache
__pycache__/
*.pyc
.pytest_cache/
# phase execution outputs
phases/**/phase*-output.json
phases/**/step*-output.json
+57
View File
@@ -0,0 +1,57 @@
# 프로젝트: Codex Markdown Document Harness Template
## 목적
이 템플릿은 Codex와 Harness Engineering 방식을 이용해 사용자의 목표에 맞는 Markdown 문서를 단계적으로 작성하기 위한 작업 환경이다.
사용자는 `docs/PRD.md`에 문서의 목적, 개요, 대상 독자, 중요 키워드, 참고 자료를 입력한다. 이후 Codex는 이 정보를 기준으로 작성 규칙을 구체화하고, 조사 노트, 초안, 피드백 반영본, 최종 문서를 순차적으로 만든다.
## Codex 구성
- `AGENTS.md`: Codex가 항상 참고하는 프로젝트 규칙.
- `.agents/skills/document-harness/`: 문서 작성 Harness의 단계별 실행 절차.
- `.agents/skills/document-review/`: 문서 변경 사항 리뷰 절차.
- `.codex/agents/`: 조사, 초안, 리뷰에 특화된 Codex custom agents.
- `.codex/hooks.json`: 문서 검증과 위험 명령 방지를 위한 lifecycle hooks.
- `.codex/config.toml`: hooks, multi-agent, live web search 등 이 템플릿에서 권장하는 Codex 기능 설정.
## 기본 산출물
- `docs/PRD.md`: 사용자가 작성하는 문서 요구사항의 원천.
- `docs/ResearchNote.md`: 웹 조사 결과, 출처, 쟁점, 문서 반영 메모.
- `drafts/`: 사용자 검토를 위한 초안 문서.
- `final/`: 피드백을 반영한 최종 문서.
- `docs/DraftFeedback.md`: 초안 검토 후 사용자가 남기는 피드백.
- `docs/FinalFeedback.md`: 최종 문서 검토 후 사용자가 남기는 피드백.
- `phases/`: Harness step 실행 계획과 상태 파일.
## 문서 작성 규칙
- CRITICAL: `docs/PRD.md`를 단일 요구사항 원천으로 삼는다. PRD에 없는 목표, 독자, 범위, 톤을 임의로 추가하지 마라.
- CRITICAL: 외부 사실, 통계, 최신 정보, 인용, 법/제도/가격/제품 정보는 `docs/ResearchNote.md`의 출처에 근거해야 한다.
- CRITICAL: 출처가 불명확한 주장을 최종 문서에 단정적으로 쓰지 마라. 필요한 경우 "확인 필요" 또는 "출처 필요"로 표시한다.
- CRITICAL: 초안 작성 후와 최종 문서 작성 후에는 사용자 피드백을 받아야 한다. 피드백이 필요한 step은 `blocked` 상태와 구체적인 `blocked_reason`을 기록한다.
- CRITICAL: 최종 문서는 초안과 분리해 `final/` 아래에 작성한다. 초안 파일을 최종본처럼 덮어쓰지 마라.
- 조사 노트에는 검색 일시, 검색어, 출처 URL, 핵심 요지, 문서 반영 여부를 남긴다.
- 문서 구조는 제목 계층을 유지한다. `#`는 문서 제목에만 사용하고, 본문 구조는 `##`, `###`를 사용한다.
- 사용자의 피드백은 삭제하지 말고 별도 피드백 문서에 보존한다.
## Codex 작업 규칙
- 반복 가능한 절차는 `.agents/skills/`의 Skill에 둔다. `AGENTS.md`에는 지속적으로 적용할 짧은 규칙만 유지한다.
- 문서 작성 Harness를 실행하거나 설계할 때는 `$document-harness` Skill을 우선 사용한다.
- 문서 변경 사항을 검토할 때는 `$document-review` Skill을 사용한다.
- 병렬 조사나 독립 리뷰가 필요한 경우 `.codex/agents/`의 custom agents를 명시적으로 선택한다.
- `scripts/execute.py`가 step 실행 후 git commit을 처리하므로, step을 수행하는 Codex 세션은 직접 commit하지 않는다.
## 권장 워크플로우
1. 사용자가 `docs/PRD.md`를 채운다.
2. Codex가 PRD를 읽고 `AGENTS.md`의 프로젝트별 작성 규칙을 구체화한다.
3. Codex 또는 Codex subagents가 웹 검색을 수행하고 `docs/ResearchNote.md`를 작성한다.
4. Codex가 `drafts/`에 초안을 만들고 사용자 검토를 요청한다.
5. 사용자가 `docs/DraftFeedback.md`에 피드백을 남긴다.
6. Codex가 피드백을 반영해 `final/`에 최종 문서를 작성한다.
7. 사용자가 `docs/FinalFeedback.md`에 최종 피드백 또는 승인 여부를 남긴다.
## 명령어
```bash
python scripts/validate_docs.py
python scripts/execute.py <task-name>
python scripts/execute.py <task-name> --push
python -m pytest scripts/test_execute.py
```
+259
View File
@@ -0,0 +1,259 @@
# Codex Markdown Document Harness Template
Codex 환경에서 Harness Engineering 방식으로 Markdown 문서를 단계적으로 작성하기 위한 템플릿입니다.
사용자는 `docs/PRD.md`에 만들고 싶은 문서의 목적, 대상 독자, 개요, 중요 키워드, 조사 요구사항을 작성합니다. 이후 Codex는 PRD를 기준으로 작성 규칙을 구체화하고, 웹 조사, 조사 노트, 초안, 사용자 피드백, 최종 문서를 순서대로 만들어 갑니다.
## 핵심 아이디어
이 템플릿은 한 번에 최종 문서를 쓰는 방식이 아니라, 다음 흐름을 강제합니다.
```text
PRD 작성
-> 작성 규칙 구체화
-> 웹 조사 및 ResearchNote 작성
-> drafts/ 초안 작성
-> 사용자 초안 피드백
-> final/ 최종 문서 작성
-> 사용자 최종 피드백 또는 승인
```
목표는 빠른 초안 작성보다 사용자의 의도, 출처, 피드백, 최종 산출물을 분리해 관리하는 것입니다.
## Codex 구성
```text
.
├── AGENTS.md # Codex가 읽는 프로젝트 기본 규칙
├── .agents/
│ └── skills/
│ ├── document-harness/ # 단계적 문서 작성 Skill
│ └── document-review/ # 문서 리뷰 Skill
├── .codex/
│ ├── config.toml # hooks, multi-agent, live web search 설정
│ ├── hooks.json # Stop/PreToolUse hook 연결
│ ├── hooks/ # hook 실행 스크립트
│ └── agents/ # 조사, 초안, 리뷰, 근거 점검 custom agents
├── docs/
│ ├── PRD.md # 사용자가 채우는 문서 요구사항
│ ├── ResearchNote.md # 조사 결과와 출처 장부
│ ├── DraftFeedback.md # 초안 피드백
│ ├── FinalFeedback.md # 최종 문서 피드백
│ ├── ARCHITECTURE.md # 템플릿 구조 설명
│ ├── ADR.md # 주요 설계 결정
│ └── UI_GUIDE.md # Markdown 문서 스타일 가이드
├── drafts/ # 초안 문서
├── final/ # 최종 문서
├── phases/ # 단계 실행 계획과 상태 파일
└── scripts/
├── execute.py # codex exec 기반 step 실행기
├── validate_docs.py # 템플릿 구조 검증
└── test_execute.py # 실행기 테스트
```
## 빠른 시작
1. 템플릿을 git 저장소로 준비합니다.
```bash
git init
```
`scripts/execute.py`는 브랜치 생성과 커밋을 수행하므로 자동 실행을 쓰려면 git 저장소가 필요합니다.
2. `docs/PRD.md`를 채웁니다.
최소한 아래 항목은 구체적으로 작성하는 것이 좋습니다.
- 문서 목적
- 대상 독자
- 최종 산출물
- 문서 개요
- 중요 키워드
- 핵심 질문
- 포함할 범위와 제외할 범위
- 톤과 스타일
- 조사 요구사항
- 승인 기준
3. Codex에서 Harness Skill을 사용합니다.
예시 프롬프트:
```text
$document-harness를 사용해서 docs/PRD.md를 읽고 문서 작성 phase를 설계해 주세요.
```
또는 바로 다음처럼 요청할 수 있습니다.
```text
$document-harness를 사용해서 docs/PRD.md 기준으로 작성 규칙 구체화, ResearchNote 작성, 초안 작성 단계까지 진행해 주세요.
```
4. 생성된 초안을 검토합니다.
초안은 `drafts/` 아래에 생성됩니다. 검토 후 `docs/DraftFeedback.md`에 피드백을 작성합니다.
5. 최종본을 검토합니다.
최종 문서는 `final/` 아래에 생성됩니다. 검토 후 `docs/FinalFeedback.md`에 승인 또는 추가 수정 요청을 작성합니다.
## 자동 실행 방식
Codex가 `phases/{task-name}/` 아래에 step 파일을 만든 뒤, 실행기는 각 step을 `codex exec`로 순차 실행합니다.
```bash
python scripts/execute.py <task-name>
```
원격 저장소에 push까지 하려면 다음 명령을 사용합니다.
```bash
python scripts/execute.py <task-name> --push
```
실행기가 처리하는 일:
- `feat-{task-name}` 브랜치 생성 또는 checkout
- `AGENTS.md``docs/*.md`를 매 step 프롬프트에 주입
- 완료된 step의 `summary`를 다음 step에 전달
- 실패 시 최대 3회 재시도
- step 상태를 `completed`, `blocked`, `error`로 관리
- step 완료 후 문서 변경과 메타데이터를 커밋
## 피드백 게이트
사용자 검토가 필요한 단계에서는 step이 `blocked` 상태로 멈출 수 있습니다.
초안 피드백:
```text
docs/DraftFeedback.md
```
최종 피드백:
```text
docs/FinalFeedback.md
```
피드백을 작성한 뒤 해당 step의 상태를 `pending`으로 되돌리고 다시 실행하면 다음 단계가 진행됩니다.
## Codex Skills
이 템플릿은 repo 공유 Skill을 사용합니다.
`document-harness`:
- PRD intake
- 작성 규칙 구체화
- ResearchNote 작성
- 초안 작성
- 피드백 게이트
- 최종 문서 작성
`document-review`:
- PRD 정합성 검토
- 출처 추적 검토
- 초안/최종본 분리 확인
- 피드백 반영 확인
- 문체와 Markdown 구조 검토
Codex에서 명시적으로 호출할 수 있습니다.
```text
$document-harness
$document-review
```
## Codex Custom Agents
`.codex/agents/`에는 문서 작성에 특화된 역할이 정의되어 있습니다.
| Agent | 역할 |
|-------|------|
| `doc_researcher` | PRD 키워드 조사, 출처 수집, `docs/ResearchNote.md` 작성 |
| `doc_drafter` | ResearchNote와 PRD를 바탕으로 `drafts/` 초안 작성 |
| `doc_reviewer` | PRD 정합성, 구조, 피드백 반영 여부 리뷰 |
| `evidence_checker` | 문서 주장과 ResearchNote 출처 연결 확인 |
Codex는 subagent를 항상 자동으로 생성하지 않습니다. 병렬 조사나 독립 리뷰가 필요하면 프롬프트에서 명시적으로 요청하세요.
예시:
```text
doc_researcher와 evidence_checker 역할을 사용해 핵심 키워드를 병렬 조사하고 docs/ResearchNote.md를 정리해 주세요.
```
## Hooks
`.codex/hooks.json`은 두 가지 기본 hook을 연결합니다.
- `PreToolUse`: 위험한 shell 명령을 차단합니다.
- `Stop`: 응답 종료 시 `python scripts/validate_docs.py`를 실행해 템플릿 구조를 검증합니다.
검증 실패 시 Codex가 문제를 고치도록 이어서 작업하게 만드는 용도입니다.
## 검증
템플릿 구조를 확인합니다.
```bash
python scripts/validate_docs.py
```
실행기 테스트를 실행합니다.
```bash
python -m pytest scripts/test_execute.py
```
현재 기대 결과:
```text
Document harness validation passed.
51 passed
```
## 문서 작성 규칙
- `docs/PRD.md`를 단일 요구사항 원천으로 사용합니다.
- 외부 사실, 통계, 최신 정보, 법/제도/가격/제품 정보는 `docs/ResearchNote.md`의 출처에 근거해야 합니다.
- 출처가 불명확한 내용은 최종 문서에 단정적으로 쓰지 않습니다.
- 초안은 `drafts/`, 최종 문서는 `final/`에 분리합니다.
- 사용자 피드백 파일은 삭제하거나 덮어쓰지 않습니다.
- 문서 제목은 `#`, 주요 섹션은 `##`, 하위 섹션은 `###`를 사용합니다.
## 추천 사용 프롬프트
PRD 검토:
```text
$document-harness를 사용해 docs/PRD.md가 문서 작성을 시작하기에 충분한지 검토해 주세요.
```
조사 노트 작성:
```text
$document-harness를 사용해 docs/PRD.md의 중요 키워드를 웹 조사하고 docs/ResearchNote.md를 작성해 주세요.
```
초안 작성:
```text
$document-harness를 사용해 docs/PRD.md와 docs/ResearchNote.md를 바탕으로 drafts/에 초안을 작성해 주세요.
```
문서 리뷰:
```text
$document-review를 사용해 drafts/와 final/의 변경 사항을 검토해 주세요.
```
## 주의사항
- 자동 실행기는 git 저장소를 전제로 합니다.
- 최신 정보가 중요한 문서는 `docs/ResearchNote.md`에 조사 일시와 기준일을 남겨야 합니다.
- `AGENTS.md`에는 지속적으로 적용할 규칙만 두고, 긴 절차는 Skill에 둡니다.
- Claude Code용 `.claude/` 구조는 사용하지 않습니다. 이 템플릿은 Codex의 `AGENTS.md`, Skill, hook, custom agent 구조를 기준으로 합니다.
+48
View File
@@ -0,0 +1,48 @@
# Architecture Decision Records
## 철학
이 템플릿의 핵심 가치는 사용자의 의도를 보존하면서도, 조사와 피드백을 통해 Markdown 문서 품질을 단계적으로 높이는 것이다. 빠르게 초안을 만들되, 근거 없는 최종본을 만들지 않는다.
---
### ADR-001: Markdown-first 문서 산출
**결정**: 모든 중간 산출물과 최종 산출물은 Markdown으로 작성한다.
**이유**: Markdown은 버전 관리, 리뷰, 재사용, 자동 변환에 적합하고 AI Agent가 구조를 안정적으로 다루기 쉽다.
**트레이드오프**: PDF, DOCX, 슬라이드 같은 최종 배포 형식은 별도 변환 단계가 필요하다.
### ADR-002: PRD를 단일 요구사항 원천으로 사용
**결정**: `docs/PRD.md`를 문서 목적, 독자, 범위, 톤, 키워드의 기준으로 삼는다.
**이유**: 단계가 길어질수록 AI Agent가 임의로 목표를 확장할 위험이 있다. 단일 원천을 두면 초안과 최종본을 같은 기준으로 평가할 수 있다.
**트레이드오프**: PRD가 빈약하면 후속 산출물도 흐려진다. 필요한 경우 PRD 보강을 먼저 요청해야 한다.
### ADR-003: ResearchNote를 출처 장부로 사용
**결정**: 웹 조사 결과와 출처 검증은 `docs/ResearchNote.md`에 먼저 정리한 뒤 문서에 반영한다.
**이유**: 최종 문서에서 어떤 주장에 어떤 근거가 사용되었는지 추적할 수 있다.
**트레이드오프**: 짧은 문서라도 조사 단계가 하나 추가된다. 대신 사실 오류와 출처 누락 위험을 줄인다.
### ADR-004: 피드백 지점은 blocked 상태로 표현
**결정**: 사용자 검토가 필요한 step은 `blocked` 상태와 구체적인 `blocked_reason`을 기록한다.
**이유**: Harness 실행기가 사용자 개입이 필요한 지점을 명확히 멈출 수 있다.
**트레이드오프**: 사용자가 피드백을 작성한 뒤 상태를 `pending`으로 되돌려 재실행해야 한다.
### ADR-005: 초안과 최종본 분리
**결정**: 초안은 `drafts/`, 최종본은 `final/`에 저장한다.
**이유**: 사용자 검토 흔적과 최종 납품물을 명확히 분리할 수 있다.
**트레이드오프**: 파일 수가 늘어난다. 대신 리뷰와 회귀 확인이 쉬워진다.
### ADR-006: Codex의 AGENTS/Skill/Hook 구조로 이전
**결정**: Claude 전용 `CLAUDE.md`, `.claude/commands`, `.claude/settings.json` 구조를 Codex의 `AGENTS.md`, `.agents/skills`, `.codex/hooks.json`, `.codex/agents` 구조로 이전한다.
**이유**: Codex는 프로젝트 지침을 `AGENTS.md`로 읽고, 재사용 가능한 워크플로우를 Skill로 관리하며, lifecycle hook과 custom agent를 별도 디렉토리에서 구성한다. 템플릿의 의도를 Codex의 네이티브 구조에 맞추면 실행 맥락과 재사용성이 좋아진다.
**트레이드오프**: Claude Code와의 직접 호환성은 낮아진다. 대신 Codex CLI, Skill, custom agent, hook을 기준으로 한 문서 작성 자동화가 명확해진다.
+74
View File
@@ -0,0 +1,74 @@
# 문서 작성 하네스 아키텍처
## 디렉토리 구조
```text
.
├── AGENTS.md # Codex가 읽는 프로젝트별 문서 작성 규칙
├── .agents/
│ └── skills/
│ ├── document-harness/ # 단계적 문서 작성 Skill
│ └── document-review/ # 문서 리뷰 Skill
├── .codex/
│ ├── config.toml # Codex 기능, live web search, agent 한도 설정
│ ├── hooks.json # Stop/PreToolUse hook 설정
│ ├── hooks/ # hook 실행 스크립트
│ └── agents/ # 조사/초안/리뷰 custom agents
├── docs/
│ ├── PRD.md # 사용자 요구사항 원천
│ ├── ResearchNote.md # 조사 노트와 출처 장부
│ ├── DraftFeedback.md # 초안 피드백
│ ├── FinalFeedback.md # 최종 문서 피드백
│ ├── ARCHITECTURE.md # 하네스 구조
│ ├── ADR.md # 문서 작성 의사결정
│ └── UI_GUIDE.md # Markdown 스타일 가이드
├── drafts/ # 검토용 초안 산출물
├── final/ # 피드백 반영 최종 산출물
├── phases/ # Harness task/step 계획과 상태
└── scripts/
├── execute.py # codex exec 기반 step 순차 실행기
├── validate_docs.py # 문서 템플릿 기본 검증
└── test_execute.py # execute.py 안전망 테스트
```
## 데이터 흐름
```text
사용자 입력
-> docs/PRD.md
-> AGENTS.md 작성 규칙 구체화
-> Codex/custom agents 웹 검색 및 출처 검증
-> docs/ResearchNote.md
-> drafts/ 초안 작성
-> docs/DraftFeedback.md 사용자 피드백
-> final/ 최종 문서 작성
-> docs/FinalFeedback.md 최종 피드백 또는 승인
```
## Step 설계 패턴
권장 phase는 아래 순서를 따른다.
1. `rule-synthesis`: `docs/PRD.md`를 읽고 `AGENTS.md`의 문서 작성 규칙을 프로젝트에 맞게 구체화한다.
2. `research-note`: 웹 검색과 사용자가 제공한 자료를 바탕으로 `docs/ResearchNote.md`를 작성한다. 필요하면 `doc_researcher` agent를 사용한다.
3. `draft-documents`: `drafts/`에 사용자 검토용 초안을 작성한다.
4. `draft-feedback-gate`: `docs/DraftFeedback.md`가 비어 있으면 `blocked`로 멈추고 사용자 검토를 요청한다.
5. `final-documents`: 피드백을 반영해 `final/`에 최종 문서를 작성한다.
6. `final-feedback-gate`: `docs/FinalFeedback.md`에 승인 또는 추가 수정 요청이 없으면 `blocked`로 멈춘다.
## Codex 구성 책임
- `AGENTS.md`는 Codex가 매 작업에서 읽는 짧고 지속적인 규칙을 담는다.
- `.agents/skills/document-harness/`는 phase 생성, research, draft, feedback gate, final 작성 절차를 담는다.
- `.agents/skills/document-review/`는 변경된 Markdown 문서의 리뷰 체크리스트를 담는다.
- `.codex/agents/`는 조사, 초안 작성, 리뷰, 근거 점검 역할을 분리한다.
- `.codex/hooks.json`은 위험 명령 차단과 Stop 시점 문서 검증을 연결한다.
## 상태 관리
- `pending`: 아직 실행되지 않은 step.
- `completed`: step 산출물이 생성되었고 검증이 끝난 상태.
- `blocked`: 사용자 피드백, 자료 제공, 승인 등 외부 입력이 필요한 상태.
- `error`: 자동 수정 3회 후에도 실패한 상태.
## 파일 책임
- `docs/PRD.md`는 사용자의 의도와 요구사항을 보존한다. Codex가 임의로 요구사항을 바꾸지 않는다.
- `docs/ResearchNote.md`는 사실 검증의 근거 장부다. 최종 문서의 외부 주장은 이 파일의 출처와 연결되어야 한다.
- `drafts/`는 논의용이다. 문장이 거칠 수 있지만 구조와 근거는 검토 가능해야 한다.
- `final/`은 납품용이다. 사용자 피드백, 출처, 스타일 기준을 반영해야 한다.
- `phases/``index.json``stepN.md`는 독립 실행 가능한 작업 지시서다.
+23
View File
@@ -0,0 +1,23 @@
# Draft Feedback
초안 검토 후 사용자가 피드백을 남기는 파일이다. AI Agent는 이 파일을 읽고 `final/` 문서에 반영한다.
## 검토 대상
- `drafts/{파일명}`
## 전체 판단
- {예: 방향 승인 / 구조 수정 필요 / 추가 조사 필요 / 톤 변경 필요}
## 수정 요청
| 위치 | 요청 | 이유 |
|------|------|------|
| {섹션 또는 파일명} | {수정 요청} | {왜 필요한지} |
## 추가로 포함할 내용
- {추가 내용}
## 제외하거나 줄일 내용
- {삭제/축소할 내용}
## 승인 여부
{예: 초안 방향 승인 / 아직 승인하지 않음}
+17
View File
@@ -0,0 +1,17 @@
# Final Feedback
최종 문서 검토 후 사용자가 승인 또는 추가 수정 요청을 남기는 파일이다.
## 검토 대상
- `final/{파일명}`
## 승인 여부
{예: 승인 / 수정 후 승인 / 승인하지 않음}
## 최종 수정 요청
| 위치 | 요청 | 우선순위 |
|------|------|----------|
| {섹션 또는 파일명} | {수정 요청} | {높음/중간/낮음} |
## 비고
- {추가 의견}
+68
View File
@@ -0,0 +1,68 @@
# PRD: {문서 프로젝트명}
이 파일은 Codex 문서 작성 Harness의 출발점이다. 사용자는 아래 항목을 가능한 한 구체적으로 채운다. Codex는 이 문서를 기준으로 작성 규칙, 조사 계획, 초안, 최종 문서를 만든다.
## 문서 목적
{이 문서가 해결하려는 문제, 설득하려는 주장, 설명하려는 주제, 또는 독자가 얻어야 할 결과를 한 문단으로 작성}
## 대상 독자
- 주요 독자: {예: 경영진, 개발자, 학생, 고객, 정책 담당자}
- 독자의 배경지식: {초급/중급/전문가, 알고 있다고 가정해도 되는 것}
- 독자가 문서를 읽은 뒤 해야 할 행동: {결정, 학습, 실행, 검토, 공유 등}
## 최종 산출물
| 문서명 | 목적 | 예상 분량 | 필수 포함 요소 |
|--------|------|-----------|----------------|
| {예: executive-summary.md} | {요약/설득/보고} | {예: 2쪽} | {핵심 메시지, 근거, 권고안} |
| {예: full-report.md} | {상세 설명} | {예: 10쪽} | {배경, 분석, 결론, 참고문헌} |
## 문서 개요
{원하는 목차, 포함해야 할 흐름, 반드시 다뤄야 할 섹션을 작성}
## 중요 키워드
- {키워드 1}
- {키워드 2}
- {키워드 3}
## 핵심 질문
- {문서가 반드시 답해야 하는 질문 1}
- {문서가 반드시 답해야 하는 질문 2}
- {문서가 반드시 답해야 하는 질문 3}
## 범위
### 포함할 것
- {포함 범위 1}
- {포함 범위 2}
### 제외할 것
- {제외 범위 1}
- {제외 범위 2}
## 톤과 스타일
- 톤: {예: 전문적, 차분한 보고서, 친근한 설명문, 강한 설득형}
- 언어: {예: 한국어, 영어, 한영 병기}
- 문체: {예: 간결한 문장, 긴 분석형 문단, bullet 중심}
- 금지 표현: {예: 과장 광고 문구, "혁신적인", "압도적인" 같은 근거 없는 표현}
## 참고 자료
사용자가 이미 가진 자료나 반드시 참고해야 할 링크를 적는다.
| 제목 | URL 또는 파일 경로 | 참고 이유 |
|------|-------------------|-----------|
| {자료명} | {URL/path} | {왜 중요한지} |
## 조사 요구사항
- 검색해야 할 주제: {예: 시장 규모, 기술 동향, 경쟁 사례, 법적 요건}
- 선호 출처: {예: 공식 문서, 학술 논문, 정부/기관 자료, 기업 보고서}
- 피해야 할 출처: {예: 출처 불명 블로그, 홍보성 기사}
- 최신성 기준: {예: 최근 2년, 2025년 이후, 최신 버전}
## 품질 기준
- {예: 모든 핵심 주장에 출처를 달 것}
- {예: 결론 전에 대안과 반론을 함께 검토할 것}
- {예: 초안은 빠르게, 최종본은 문장 품질과 일관성을 엄격히 볼 것}
## 사용자 피드백 방식
- 초안 피드백 위치: `docs/DraftFeedback.md`
- 최종 피드백 위치: `docs/FinalFeedback.md`
- 승인 기준: {예: 사용자가 명시적으로 "승인"이라고 남기면 완료}
+54
View File
@@ -0,0 +1,54 @@
# Research Note: {문서 프로젝트명}
이 파일은 조사 내용과 출처를 보존하는 장부다. 최종 문서에 들어가는 외부 사실, 통계, 인용, 사례는 가능한 한 이 파일의 항목과 연결되어야 한다.
## 조사 범위
- 기준 PRD: `docs/PRD.md`
- 조사 주제: {조사할 주제}
- 제외 주제: {조사하지 않을 주제}
- 최신성 기준: {예: 최근 2년, 2025년 이후, 최신 공식 문서}
## 조사 일시
- 시작: {YYYY-MM-DD HH:mm, timezone}
- 종료: {YYYY-MM-DD HH:mm, timezone}
- 조사자: AI Agent
## 검색어
| 검색어 | 목적 | 결과 메모 |
|--------|------|-----------|
| {검색어} | {무엇을 확인하려 했는지} | {핵심 결과} |
## 핵심 결론
1. {조사에서 확인한 핵심 결론}
2. {조사에서 확인한 핵심 결론}
3. {조사에서 확인한 핵심 결론}
## 출처 목록
| ID | 제목 | URL | 게시일/확인일 | 신뢰도 | 관련 키워드 |
|----|------|-----|---------------|--------|-------------|
| S1 | {출처 제목} | {URL} | {날짜} | {공식/학술/언론/블로그 등} | {키워드} |
## 출처별 메모
### S1: {출처 제목}
- URL: {URL}
- 요지: {핵심 내용 요약}
- 문서에 쓸 수 있는 내용: {반영할 사실/사례/근거}
- 주의사항: {한계, 편향, 오래된 정보, 상충 자료}
## 키워드별 정리
### {키워드}
- 확인된 사실: {내용}
- 관련 출처: {S1, S2}
- 문서 반영 위치: {draft/final의 예상 섹션}
## 쟁점과 상반된 주장
| 쟁점 | 주장 A | 주장 B | 판단/처리 |
|------|--------|--------|-----------|
| {쟁점} | {내용과 출처} | {내용과 출처} | {문서에서 어떻게 다룰지} |
## 확인 필요
- {추가 확인이 필요한 사실}
- {사용자에게 물어봐야 할 내용}
## 문서 반영 메모
- {어떤 결론을 어떤 문서/섹션에 반영할지}
+54
View File
@@ -0,0 +1,54 @@
# Markdown 문서 스타일 가이드
## 원칙
1. 독자의 다음 행동이 분명해야 한다. 설명문이라면 이해, 보고서라면 판단, 가이드라면 실행이 가능해야 한다.
2. 근거와 의견을 섞지 않는다. 사실, 해석, 권고를 구분해 쓴다.
3. AI가 쓴 듯한 일반론보다 사용자의 목적과 키워드에 맞춘 구체성을 우선한다.
## AI 문서 안티패턴
| 금지 사항 | 이유 |
|-----------|------|
| "오늘날 빠르게 변화하는 시대에" 같은 상투적 도입 | 정보 밀도가 낮고 AI 생성문처럼 보인다 |
| 근거 없는 최상급 표현 | 신뢰를 떨어뜨린다 |
| 출처 없는 통계와 수치 | 검증할 수 없다 |
| 같은 의미의 문장을 반복해 분량 늘리기 | 독자의 시간을 낭비한다 |
| 목차와 본문 제목 불일치 | 리뷰와 유지보수가 어려워진다 |
| PRD에 없는 독자나 목표 추가 | 사용자 의도를 벗어난다 |
| ResearchNote에 없는 외부 주장 단정 | 출처 추적이 끊긴다 |
| AGENTS.md와 Skill 지침 불일치 | Codex 실행 맥락이 흔들린다 |
## 구조
- 문서 제목은 `#` 하나만 사용한다.
- 주요 섹션은 `##`, 하위 섹션은 `###`를 사용한다.
- 한 섹션에는 하나의 중심 메시지만 둔다.
- 긴 목록은 표로 바꿀 수 있는지 검토한다.
- 결론 문서라면 "요약 -> 근거 -> 판단/권고 -> 한계" 순서를 우선 고려한다.
- 설명 문서라면 "맥락 -> 핵심 개념 -> 절차/예시 -> 주의사항" 순서를 우선 고려한다.
## 문체
- 문장은 가능한 한 짧게 쓴다.
- 모호한 주어를 피한다.
- "중요하다", "효과적이다"처럼 평가를 쓸 때는 이유나 근거를 바로 붙인다.
- 불확실한 정보는 확률적 표현 또는 확인 필요 표시를 사용한다.
- 한국어 문서에서는 불필요한 영어 약어를 피하고, 처음 등장할 때 풀어쓴다.
## 출처 표기
- 외부 사실은 문장 끝이나 문단 끝에 출처 링크를 붙인다.
- 긴 직접 인용보다 요약과 해석을 우선한다.
- 같은 출처를 반복해서 사용할 때도 어떤 주장에 연결되는지 분명히 한다.
- 출처가 상충하면 `docs/ResearchNote.md`의 "쟁점/상반된 주장"에 기록한다.
## 표와 목록
- 비교, 분류, 의사결정 기준은 표를 우선 검토한다.
- 순서가 중요한 절차는 번호 목록을 사용한다.
- 단순 나열은 bullet을 사용한다.
- 표는 너무 넓어지면 섹션을 나누거나 요약 표와 상세 설명을 분리한다.
## 최종 점검
- PRD의 목적과 대상 독자에 맞는가?
- 모든 핵심 질문에 답했는가?
- 외부 주장에 출처가 있는가?
- 초안 피드백이 반영되었는가?
- 문서 제목, 섹션 제목, 파일명이 산출물 목적과 맞는가?
- 최종 문서는 `final/` 아래에 있는가?
- Codex Skill, agent, hook 지침과 충돌하지 않는가?
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+426
View File
@@ -0,0 +1,426 @@
#!/usr/bin/env python3
"""
Codex Harness Step Executor — phase 내 step을 순차 실행하고 자가 교정한다.
Usage:
python3 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들을 Codex로 순차 실행하는 하네스."""
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:
retry_section = ""
if prev_error:
retry_section = (
f"\n## ⚠ 이전 시도 실패 — 아래 에러를 반드시 참고하여 수정하라\n\n"
f"{prev_error}\n\n---\n\n"
)
return (
f"당신은 {self._project} 프로젝트의 Codex 문서 작성 에이전트입니다. 아래 step을 수행하세요.\n\n"
f"{guardrails}\n\n---\n\n"
f"{step_context}{retry_section}"
f"## 작업 규칙\n\n"
f"1. 이전 step에서 작성된 문서와 메모를 확인하고 일관성을 유지하라.\n"
f"2. 이 step에 명시된 작업만 수행하라. 추가 산출물이나 임의 요구사항을 만들지 마라.\n"
f"3. 기존 문서 구조와 피드백 기록을 깨뜨리지 마라.\n"
f"4. AC(Acceptance Criteria) 검증을 직접 실행하라.\n"
f"5. /phases/{self._phase_dir_name}/index.json의 해당 step status를 업데이트하라:\n"
f" - AC 통과 → \"completed\" + \"summary\" 필드에 이 step의 산출물을 한 줄로 요약\n"
f" - {self.MAX_RETRIES}회 수정 시도 후에도 실패 → \"error\" + \"error_message\" 기록\n"
f" - 사용자 개입이 필요한 경우 (API 키, 인증, 수동 설정 등) → \"blocked\" + \"blocked_reason\" 기록 후 즉시 중단\n"
f"6. 직접 git commit하지 마라. commit은 scripts/execute.py가 step 완료 후 수행한다.\n"
f"7. 병렬 조사나 독립 리뷰가 필요하고 step에서 허용했다면 .codex/agents의 custom agent 역할을 활용하라.\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")
cmd = ["codex", "exec", "--skip-git-repo-check", "--full-auto", "--json", "-"]
try:
result = subprocess.run(
cmd,
cwd=self._root,
input=prompt,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=1800,
)
except FileNotFoundError:
print("\n ERROR: Codex CLI를 찾을 수 없습니다. `codex --version`이 실행되는지 확인하세요.")
sys.exit(1)
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" Codex 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="Codex 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()
+560
View File
@@ -0,0 +1,560 @@
"""
execute.py 리팩터링 안전망 테스트.
리팩터링 전후 동작이 동일한지 검증한다.
"""
import json
import os
import subprocess
import sys
import textwrap
from datetime import datetime, timezone, timedelta
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
sys.path.insert(0, str(Path(__file__).parent))
import execute as ex
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_project(tmp_path):
"""phases/, AGENTS.md, docs/ 를 갖춘 임시 프로젝트 구조."""
phases_dir = tmp_path / "phases"
phases_dir.mkdir()
agents_md = tmp_path / "AGENTS.md"
agents_md.write_text("# Rules\n- rule one\n- rule two", encoding="utf-8")
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
(docs_dir / "arch.md").write_text("# Architecture\nSome content", encoding="utf-8")
(docs_dir / "guide.md").write_text("# Guide\nAnother doc", encoding="utf-8")
return tmp_path
@pytest.fixture
def phase_dir(tmp_project):
"""step 3개를 가진 phase 디렉토리."""
d = tmp_project / "phases" / "0-mvp"
d.mkdir()
index = {
"project": "TestProject",
"phase": "mvp",
"steps": [
{"step": 0, "name": "setup", "status": "completed", "summary": "프로젝트 초기화 완료"},
{"step": 1, "name": "core", "status": "completed", "summary": "핵심 로직 구현"},
{"step": 2, "name": "ui", "status": "pending"},
],
}
(d / "index.json").write_text(json.dumps(index, indent=2, ensure_ascii=False), encoding="utf-8")
(d / "step2.md").write_text("# Step 2: UI\n\nUI를 구현하세요.", encoding="utf-8")
return d
@pytest.fixture
def top_index(tmp_project):
"""phases/index.json (top-level)."""
top = {
"phases": [
{"dir": "0-mvp", "status": "pending"},
{"dir": "1-polish", "status": "pending"},
]
}
p = tmp_project / "phases" / "index.json"
p.write_text(json.dumps(top, indent=2), encoding="utf-8")
return p
@pytest.fixture
def executor(tmp_project, phase_dir):
"""테스트용 StepExecutor 인스턴스. git 호출은 별도 mock 필요."""
with patch.object(ex, "ROOT", tmp_project):
inst = ex.StepExecutor("0-mvp")
# 내부 경로를 tmp_project 기준으로 재설정
inst._root = str(tmp_project)
inst._phases_dir = tmp_project / "phases"
inst._phase_dir = phase_dir
inst._phase_dir_name = "0-mvp"
inst._index_file = phase_dir / "index.json"
inst._top_index_file = tmp_project / "phases" / "index.json"
return inst
# ---------------------------------------------------------------------------
# _stamp (= 이전 now_iso)
# ---------------------------------------------------------------------------
class TestStamp:
def test_returns_kst_timestamp(self, executor):
result = executor._stamp()
assert "+0900" in result
def test_format_is_iso(self, executor):
result = executor._stamp()
dt = datetime.strptime(result, "%Y-%m-%dT%H:%M:%S%z")
assert dt.tzinfo is not None
def test_is_current_time(self, executor):
before = datetime.now(ex.StepExecutor.TZ).replace(microsecond=0)
result = executor._stamp()
after = datetime.now(ex.StepExecutor.TZ).replace(microsecond=0) + timedelta(seconds=1)
parsed = datetime.strptime(result, "%Y-%m-%dT%H:%M:%S%z")
assert before <= parsed <= after
# ---------------------------------------------------------------------------
# _read_json / _write_json
# ---------------------------------------------------------------------------
class TestJsonHelpers:
def test_roundtrip(self, tmp_path):
data = {"key": "", "nested": [1, 2, 3]}
p = tmp_path / "test.json"
ex.StepExecutor._write_json(p, data)
loaded = ex.StepExecutor._read_json(p)
assert loaded == data
def test_save_ensures_ascii_false(self, tmp_path):
p = tmp_path / "test.json"
ex.StepExecutor._write_json(p, {"한글": "테스트"})
raw = p.read_text(encoding="utf-8")
assert "한글" in raw
assert "\\u" not in raw
def test_save_indented(self, tmp_path):
p = tmp_path / "test.json"
ex.StepExecutor._write_json(p, {"a": 1})
raw = p.read_text(encoding="utf-8")
assert "\n" in raw
def test_load_nonexistent_raises(self, tmp_path):
with pytest.raises(FileNotFoundError):
ex.StepExecutor._read_json(tmp_path / "nope.json")
# ---------------------------------------------------------------------------
# _load_guardrails
# ---------------------------------------------------------------------------
class TestLoadGuardrails:
def test_loads_agents_md_and_docs(self, executor, tmp_project):
with patch.object(ex, "ROOT", tmp_project):
result = executor._load_guardrails()
assert "# Rules" in result
assert "rule one" in result
assert "# Architecture" in result
assert "# Guide" in result
def test_sections_separated_by_divider(self, executor, tmp_project):
with patch.object(ex, "ROOT", tmp_project):
result = executor._load_guardrails()
assert "---" in result
def test_docs_sorted_alphabetically(self, executor, tmp_project):
with patch.object(ex, "ROOT", tmp_project):
result = executor._load_guardrails()
arch_pos = result.index("arch")
guide_pos = result.index("guide")
assert arch_pos < guide_pos
def test_no_agents_md(self, executor, tmp_project):
(tmp_project / "AGENTS.md").unlink()
with patch.object(ex, "ROOT", tmp_project):
result = executor._load_guardrails()
assert "AGENTS.md" not in result
assert "Architecture" in result
def test_no_docs_dir(self, executor, tmp_project):
import shutil
shutil.rmtree(tmp_project / "docs")
with patch.object(ex, "ROOT", tmp_project):
result = executor._load_guardrails()
assert "Rules" in result
assert "Architecture" not in result
def test_empty_project(self, tmp_path):
with patch.object(ex, "ROOT", tmp_path):
# executor가 필요 없는 static-like 동작이므로 임시 인스턴스
phases_dir = tmp_path / "phases" / "dummy"
phases_dir.mkdir(parents=True)
idx = {"project": "T", "phase": "t", "steps": []}
(phases_dir / "index.json").write_text(json.dumps(idx), encoding="utf-8")
inst = ex.StepExecutor.__new__(ex.StepExecutor)
result = inst._load_guardrails()
assert result == ""
# ---------------------------------------------------------------------------
# _build_step_context
# ---------------------------------------------------------------------------
class TestBuildStepContext:
def test_includes_completed_with_summary(self, phase_dir):
index = json.loads((phase_dir / "index.json").read_text(encoding="utf-8"))
result = ex.StepExecutor._build_step_context(index)
assert "Step 0 (setup): 프로젝트 초기화 완료" in result
assert "Step 1 (core): 핵심 로직 구현" in result
def test_excludes_pending(self, phase_dir):
index = json.loads((phase_dir / "index.json").read_text(encoding="utf-8"))
result = ex.StepExecutor._build_step_context(index)
assert "ui" not in result
def test_excludes_completed_without_summary(self, phase_dir):
index = json.loads((phase_dir / "index.json").read_text(encoding="utf-8"))
del index["steps"][0]["summary"]
result = ex.StepExecutor._build_step_context(index)
assert "setup" not in result
assert "core" in result
def test_empty_when_no_completed(self):
index = {"steps": [{"step": 0, "name": "a", "status": "pending"}]}
result = ex.StepExecutor._build_step_context(index)
assert result == ""
def test_has_header(self, phase_dir):
index = json.loads((phase_dir / "index.json").read_text(encoding="utf-8"))
result = ex.StepExecutor._build_step_context(index)
assert result.startswith("## 이전 Step 산출물")
# ---------------------------------------------------------------------------
# _build_preamble
# ---------------------------------------------------------------------------
class TestBuildPreamble:
def test_includes_project_name(self, executor):
result = executor._build_preamble("", "")
assert "TestProject" in result
def test_includes_guardrails(self, executor):
result = executor._build_preamble("GUARD_CONTENT", "")
assert "GUARD_CONTENT" in result
def test_includes_step_context(self, executor):
ctx = "## 이전 Step 산출물\n\n- Step 0: done"
result = executor._build_preamble("", ctx)
assert "이전 Step 산출물" in result
def test_tells_agent_not_to_commit_directly(self, executor):
result = executor._build_preamble("", "")
assert "직접 git commit하지 마라" in result
def test_includes_rules(self, executor):
result = executor._build_preamble("", "")
assert "작업 규칙" in result
assert "AC" in result
def test_no_retry_section_by_default(self, executor):
result = executor._build_preamble("", "")
assert "이전 시도 실패" not in result
def test_retry_section_with_prev_error(self, executor):
result = executor._build_preamble("", "", prev_error="타입 에러 발생")
assert "이전 시도 실패" in result
assert "타입 에러 발생" in result
def test_includes_max_retries(self, executor):
result = executor._build_preamble("", "")
assert str(ex.StepExecutor.MAX_RETRIES) in result
def test_includes_index_path(self, executor):
result = executor._build_preamble("", "")
assert "/phases/0-mvp/index.json" in result
# ---------------------------------------------------------------------------
# _update_top_index
# ---------------------------------------------------------------------------
class TestUpdateTopIndex:
def test_completed(self, executor, top_index):
executor._top_index_file = top_index
executor._update_top_index("completed")
data = json.loads(top_index.read_text(encoding="utf-8"))
mvp = next(p for p in data["phases"] if p["dir"] == "0-mvp")
assert mvp["status"] == "completed"
assert "completed_at" in mvp
def test_error(self, executor, top_index):
executor._top_index_file = top_index
executor._update_top_index("error")
data = json.loads(top_index.read_text(encoding="utf-8"))
mvp = next(p for p in data["phases"] if p["dir"] == "0-mvp")
assert mvp["status"] == "error"
assert "failed_at" in mvp
def test_blocked(self, executor, top_index):
executor._top_index_file = top_index
executor._update_top_index("blocked")
data = json.loads(top_index.read_text(encoding="utf-8"))
mvp = next(p for p in data["phases"] if p["dir"] == "0-mvp")
assert mvp["status"] == "blocked"
assert "blocked_at" in mvp
def test_other_phases_unchanged(self, executor, top_index):
executor._top_index_file = top_index
executor._update_top_index("completed")
data = json.loads(top_index.read_text(encoding="utf-8"))
polish = next(p for p in data["phases"] if p["dir"] == "1-polish")
assert polish["status"] == "pending"
def test_nonexistent_dir_is_noop(self, executor, top_index):
executor._top_index_file = top_index
executor._phase_dir_name = "no-such-dir"
original = json.loads(top_index.read_text(encoding="utf-8"))
executor._update_top_index("completed")
after = json.loads(top_index.read_text(encoding="utf-8"))
for p_before, p_after in zip(original["phases"], after["phases"]):
assert p_before["status"] == p_after["status"]
def test_no_top_index_file(self, executor, tmp_path):
executor._top_index_file = tmp_path / "nonexistent.json"
executor._update_top_index("completed") # should not raise
# ---------------------------------------------------------------------------
# _checkout_branch (mocked)
# ---------------------------------------------------------------------------
class TestCheckoutBranch:
def _mock_git(self, executor, responses):
call_idx = {"i": 0}
def fake_git(*args):
idx = call_idx["i"]
call_idx["i"] += 1
if idx < len(responses):
return responses[idx]
return MagicMock(returncode=0, stdout="", stderr="")
executor._run_git = fake_git
def test_already_on_branch(self, executor):
self._mock_git(executor, [
MagicMock(returncode=0, stdout="feat-mvp\n", stderr=""),
])
executor._checkout_branch() # should return without checkout
def test_branch_exists_checkout(self, executor):
self._mock_git(executor, [
MagicMock(returncode=0, stdout="main\n", stderr=""),
MagicMock(returncode=0, stdout="", stderr=""),
MagicMock(returncode=0, stdout="", stderr=""),
])
executor._checkout_branch()
def test_branch_not_exists_create(self, executor):
self._mock_git(executor, [
MagicMock(returncode=0, stdout="main\n", stderr=""),
MagicMock(returncode=1, stdout="", stderr="not found"),
MagicMock(returncode=0, stdout="", stderr=""),
])
executor._checkout_branch()
def test_checkout_fails_exits(self, executor):
self._mock_git(executor, [
MagicMock(returncode=0, stdout="main\n", stderr=""),
MagicMock(returncode=1, stdout="", stderr=""),
MagicMock(returncode=1, stdout="", stderr="dirty tree"),
])
with pytest.raises(SystemExit) as exc_info:
executor._checkout_branch()
assert exc_info.value.code == 1
def test_no_git_exits(self, executor):
self._mock_git(executor, [
MagicMock(returncode=1, stdout="", stderr="not a git repo"),
])
with pytest.raises(SystemExit) as exc_info:
executor._checkout_branch()
assert exc_info.value.code == 1
# ---------------------------------------------------------------------------
# _commit_step (mocked)
# ---------------------------------------------------------------------------
class TestCommitStep:
def test_two_phase_commit(self, executor):
calls = []
def fake_git(*args):
calls.append(args)
if args[:2] == ("diff", "--cached"):
return MagicMock(returncode=1)
return MagicMock(returncode=0, stdout="", stderr="")
executor._run_git = fake_git
executor._commit_step(2, "ui")
commit_calls = [c for c in calls if c[0] == "commit"]
assert len(commit_calls) == 2
assert "feat(mvp):" in commit_calls[0][2]
assert "chore(mvp):" in commit_calls[1][2]
def test_no_code_changes_skips_feat_commit(self, executor):
call_count = {"diff": 0}
calls = []
def fake_git(*args):
calls.append(args)
if args[:2] == ("diff", "--cached"):
call_count["diff"] += 1
if call_count["diff"] == 1:
return MagicMock(returncode=0)
return MagicMock(returncode=1)
return MagicMock(returncode=0, stdout="", stderr="")
executor._run_git = fake_git
executor._commit_step(2, "ui")
commit_msgs = [c[2] for c in calls if c[0] == "commit"]
assert len(commit_msgs) == 1
assert "chore" in commit_msgs[0]
# ---------------------------------------------------------------------------
# _invoke_codex (mocked)
# ---------------------------------------------------------------------------
class TestInvokeCodex:
def test_invokes_codex_with_correct_args(self, executor):
mock_result = MagicMock(returncode=0, stdout='{"result": "ok"}', stderr="")
step = {"step": 2, "name": "ui"}
preamble = "PREAMBLE\n"
with patch("subprocess.run", return_value=mock_result) as mock_run:
output = executor._invoke_codex(step, preamble)
cmd = mock_run.call_args[0][0]
assert cmd[:2] == ["codex", "exec"]
assert "--skip-git-repo-check" in cmd
assert "--full-auto" in cmd
assert "--json" in cmd
assert cmd[-1] == "-"
assert "PREAMBLE" in mock_run.call_args[1]["input"]
assert "UI를 구현하세요" in mock_run.call_args[1]["input"]
def test_saves_output_json(self, executor):
mock_result = MagicMock(returncode=0, stdout='{"ok": true}', stderr="")
step = {"step": 2, "name": "ui"}
with patch("subprocess.run", return_value=mock_result):
executor._invoke_codex(step, "preamble")
output_file = executor._phase_dir / "step2-output.json"
assert output_file.exists()
data = json.loads(output_file.read_text(encoding="utf-8"))
assert data["step"] == 2
assert data["name"] == "ui"
assert data["exitCode"] == 0
def test_nonexistent_step_file_exits(self, executor):
step = {"step": 99, "name": "nonexistent"}
with pytest.raises(SystemExit) as exc_info:
executor._invoke_codex(step, "preamble")
assert exc_info.value.code == 1
def test_timeout_is_1800(self, executor):
mock_result = MagicMock(returncode=0, stdout="{}", stderr="")
step = {"step": 2, "name": "ui"}
with patch("subprocess.run", return_value=mock_result) as mock_run:
executor._invoke_codex(step, "preamble")
assert mock_run.call_args[1]["timeout"] == 1800
# ---------------------------------------------------------------------------
# progress_indicator (= 이전 Spinner)
# ---------------------------------------------------------------------------
class TestProgressIndicator:
def test_context_manager(self):
import time
with ex.progress_indicator("test") as pi:
time.sleep(0.15)
assert pi.elapsed >= 0.1
def test_elapsed_increases(self):
import time
with ex.progress_indicator("test") as pi:
time.sleep(0.2)
assert pi.elapsed > 0
# ---------------------------------------------------------------------------
# main() CLI 파싱 (mocked)
# ---------------------------------------------------------------------------
class TestMainCli:
def test_no_args_exits(self):
with patch("sys.argv", ["execute.py"]):
with pytest.raises(SystemExit) as exc_info:
ex.main()
assert exc_info.value.code == 2 # argparse exits with 2
def test_invalid_phase_dir_exits(self):
with patch("sys.argv", ["execute.py", "nonexistent"]):
with patch.object(ex, "ROOT", Path("/tmp/fake_nonexistent")):
with pytest.raises(SystemExit) as exc_info:
ex.main()
assert exc_info.value.code == 1
def test_missing_index_exits(self, tmp_project):
(tmp_project / "phases" / "empty").mkdir()
with patch("sys.argv", ["execute.py", "empty"]):
with patch.object(ex, "ROOT", tmp_project):
with pytest.raises(SystemExit) as exc_info:
ex.main()
assert exc_info.value.code == 1
# ---------------------------------------------------------------------------
# _check_blockers (= 이전 main() error/blocked 체크)
# ---------------------------------------------------------------------------
class TestCheckBlockers:
def _make_executor_with_steps(self, tmp_project, steps):
d = tmp_project / "phases" / "test-phase"
d.mkdir(exist_ok=True)
index = {"project": "T", "phase": "test", "steps": steps}
(d / "index.json").write_text(json.dumps(index), encoding="utf-8")
with patch.object(ex, "ROOT", tmp_project):
inst = ex.StepExecutor.__new__(ex.StepExecutor)
inst._root = str(tmp_project)
inst._phases_dir = tmp_project / "phases"
inst._phase_dir = d
inst._phase_dir_name = "test-phase"
inst._index_file = d / "index.json"
inst._top_index_file = tmp_project / "phases" / "index.json"
inst._phase_name = "test"
inst._total = len(steps)
return inst
def test_error_step_exits_1(self, tmp_project):
steps = [
{"step": 0, "name": "ok", "status": "completed"},
{"step": 1, "name": "bad", "status": "error", "error_message": "fail"},
]
inst = self._make_executor_with_steps(tmp_project, steps)
with pytest.raises(SystemExit) as exc_info:
inst._check_blockers()
assert exc_info.value.code == 1
def test_blocked_step_exits_2(self, tmp_project):
steps = [
{"step": 0, "name": "ok", "status": "completed"},
{"step": 1, "name": "stuck", "status": "blocked", "blocked_reason": "API key"},
]
inst = self._make_executor_with_steps(tmp_project, steps)
with pytest.raises(SystemExit) as exc_info:
inst._check_blockers()
assert exc_info.value.code == 2
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""
Basic validation for the Markdown document harness template.
This check is intentionally lightweight: it verifies that the template files
exist and keep the sections that later Harness steps depend on.
"""
import json
from pathlib import Path
import sys
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover - Python < 3.11 compatibility
tomllib = None
ROOT = Path(__file__).resolve().parent.parent
REQUIRED_FILES = [
"README.md",
"AGENTS.md",
"docs/PRD.md",
"docs/ResearchNote.md",
"docs/DraftFeedback.md",
"docs/FinalFeedback.md",
"docs/ARCHITECTURE.md",
"docs/ADR.md",
"docs/UI_GUIDE.md",
".agents/skills/document-harness/SKILL.md",
".agents/skills/document-harness/references/phase-templates.md",
".agents/skills/document-review/SKILL.md",
".codex/config.toml",
".codex/hooks.json",
".codex/hooks/pre_tool_guard.py",
".codex/hooks/stop_validate.py",
".codex/agents/doc_researcher.toml",
".codex/agents/doc_drafter.toml",
".codex/agents/doc_reviewer.toml",
".codex/agents/evidence_checker.toml",
]
REQUIRED_DIRS = [
"docs",
"scripts",
".agents",
".agents/skills",
".agents/skills/document-harness",
".agents/skills/document-review",
".codex",
".codex/hooks",
".codex/agents",
]
REQUIRED_SECTIONS = {
"README.md": [
"## 핵심 아이디어",
"## Codex 구성",
"## 빠른 시작",
"## 자동 실행 방식",
"## 피드백 게이트",
"## 검증",
],
"docs/PRD.md": [
"## 문서 목적",
"## 대상 독자",
"## 최종 산출물",
"## 문서 개요",
"## 중요 키워드",
"## 핵심 질문",
"## 범위",
"## 톤과 스타일",
"## 조사 요구사항",
"## 사용자 피드백 방식",
],
"docs/ResearchNote.md": [
"## 조사 범위",
"## 조사 일시",
"## 검색어",
"## 핵심 결론",
"## 출처 목록",
"## 쟁점과 상반된 주장",
"## 확인 필요",
],
"AGENTS.md": [
"## 목적",
"## Codex 구성",
"## 기본 산출물",
"## 문서 작성 규칙",
"## Codex 작업 규칙",
"## 권장 워크플로우",
"## 명령어",
],
".agents/skills/document-harness/SKILL.md": [
"# Document Harness Skill",
"## Operating Rules",
"## Staged Workflow",
"## Validation",
],
".agents/skills/document-review/SKILL.md": [
"# Document Review Skill",
"## Read First",
"## Review Checklist",
"## Output Format",
],
}
REQUIRED_JSON_FILES = [
".codex/hooks.json",
]
REQUIRED_TOML_FILES = [
".codex/config.toml",
".codex/agents/doc_researcher.toml",
".codex/agents/doc_drafter.toml",
".codex/agents/doc_reviewer.toml",
".codex/agents/evidence_checker.toml",
]
def first_nonempty_line(path: Path) -> str:
for line in path.read_text(encoding="utf-8").splitlines():
if line.strip():
return line.strip()
return ""
def markdown_file_has_valid_start(path: Path) -> bool:
first = first_nonempty_line(path)
if first.startswith("# "):
return True
if first == "---" and path.name == "SKILL.md":
return True
return False
def main() -> int:
errors: list[str] = []
for rel in REQUIRED_DIRS:
path = ROOT / rel
if not path.is_dir():
errors.append(f"missing directory: {rel}")
for rel in REQUIRED_FILES:
path = ROOT / rel
if not path.is_file():
errors.append(f"missing file: {rel}")
continue
if path.suffix == ".md":
if not markdown_file_has_valid_start(path):
errors.append(f"markdown file must start with a level-1 heading or Skill frontmatter: {rel}")
for rel, sections in REQUIRED_SECTIONS.items():
path = ROOT / rel
if not path.is_file():
continue
text = path.read_text(encoding="utf-8")
for section in sections:
if section not in text:
errors.append(f"missing section in {rel}: {section}")
for rel in REQUIRED_JSON_FILES:
path = ROOT / rel
if not path.is_file():
continue
try:
json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
errors.append(f"invalid JSON in {rel}: {exc}")
if tomllib is not None:
for rel in REQUIRED_TOML_FILES:
path = ROOT / rel
if not path.is_file():
continue
try:
tomllib.loads(path.read_text(encoding="utf-8"))
except tomllib.TOMLDecodeError as exc:
errors.append(f"invalid TOML in {rel}: {exc}")
if errors:
print("Document harness validation failed:")
for error in errors:
print(f"- {error}")
return 1
print("Document harness validation passed.")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,131 @@
---
name: "document-harness"
description: "Use when creating, running, or updating the staged Markdown document-writing Harness from docs/PRD.md through research notes, drafts, feedback gates, and final documents."
---
# Document Harness Skill
Use this skill to turn `docs/PRD.md` into researched, reviewable, and feedback-driven Markdown documents.
## Operating Rules
1. Read `AGENTS.md`, `docs/PRD.md`, `docs/ARCHITECTURE.md`, `docs/ADR.md`, and `docs/UI_GUIDE.md` before planning document work.
2. Treat `docs/PRD.md` as the single source of requirements.
3. If PRD purpose, target reader, final deliverables, scope, or key questions are materially empty, stop and ask the user to complete PRD first.
4. Use `docs/ResearchNote.md` as the evidence ledger before drafting externally factual content.
5. Store review drafts under `drafts/` and final deliverables under `final/`.
6. Preserve `docs/DraftFeedback.md` and `docs/FinalFeedback.md`; never delete user feedback.
7. Run `python scripts/validate_docs.py` before reporting completion.
## Staged Workflow
### 1. PRD Intake
Read `docs/PRD.md` and identify:
- document purpose
- target readers
- final deliverables
- required outline
- important keywords
- key questions
- scope boundaries
- tone and style constraints
- research requirements
### 2. Rule Synthesis
Update only the relevant project-specific guidance in `AGENTS.md`.
Include:
- document purpose and target readers
- final deliverables
- tone and style rules
- citation and verification standards
- draft and final feedback process
Keep the generic Codex configuration and repository workflow concise.
### 3. Research Note
Research PRD keywords and key questions. Prefer official, academic, government, institutional, or other primary sources.
Write `docs/ResearchNote.md` with:
- search date
- search terms
- source URLs
- source quality notes
- core findings
- conflicting claims
- unresolved questions
- intended document usage
Use `doc_researcher` or `evidence_checker` agents when the user or current phase explicitly asks for subagent work.
### 4. Draft Documents
Create all PRD deliverables under `drafts/`.
Drafts must:
- answer the PRD key questions
- stay inside PRD scope
- use the requested tone
- link factual claims to `docs/ResearchNote.md`
- mark weak or missing evidence
After drafting, request user review in `docs/DraftFeedback.md`.
### 5. Draft Feedback Gate
If `docs/DraftFeedback.md` has no actionable user feedback or approval, mark the phase step as `blocked` with a clear `blocked_reason`.
If feedback exists, summarize it before revising.
### 6. Final Documents
Create final deliverables under `final/`. Do not overwrite `drafts/`.
Final documents must reflect:
- PRD requirements
- ResearchNote evidence
- DraftFeedback requests
- UI guide style rules
After finalizing, request user review or approval in `docs/FinalFeedback.md`.
### 7. Final Feedback Gate
If `docs/FinalFeedback.md` does not contain approval or actionable next feedback, mark the phase step as `blocked`.
If approval exists, mark the phase completed.
## Phase Files
When creating a new phase, use `references/phase-templates.md`.
Each step must include:
- files to read
- exact task
- acceptance criteria
- validation procedure
- status update instructions
- concrete forbidden actions
## Validation
Always run:
```bash
python scripts/validate_docs.py
```
For executor changes, also run:
```bash
python -m pytest scripts/test_execute.py
```
@@ -0,0 +1,85 @@
# Document Harness Phase Templates
## Top-Level Phase Index
Create or update `phases/index.json`.
```json
{
"phases": [
{
"dir": "0-document",
"status": "pending"
}
]
}
```
## Task Index
Create `phases/{task-name}/index.json`.
```json
{
"project": "<문서 프로젝트명>",
"phase": "<task-name>",
"steps": [
{ "step": 0, "name": "rule-synthesis", "status": "pending" },
{ "step": 1, "name": "research-note", "status": "pending" },
{ "step": 2, "name": "draft-documents", "status": "pending" },
{ "step": 3, "name": "draft-feedback-gate", "status": "pending" },
{ "step": 4, "name": "final-documents", "status": "pending" },
{ "step": 5, "name": "final-feedback-gate", "status": "pending" }
]
}
```
## Step File
Create `phases/{task-name}/step{N}.md`.
```markdown
# Step {N}: {이름}
## 읽어야 할 파일
먼저 아래 파일들을 읽고 문서 목적과 작성 기준을 파악하라:
- `/AGENTS.md`
- `/docs/PRD.md`
- `/docs/ARCHITECTURE.md`
- `/docs/ADR.md`
- `/docs/UI_GUIDE.md`
- {이전 step에서 생성/수정된 파일 경로}
## 작업
{구체적인 문서 작성 또는 검토 지시. 파일 경로, 산출물 이름, 반영해야 할 PRD 항목, 출처 기준을 포함한다.}
## Acceptance Criteria
```bash
python scripts/validate_docs.py
```
## 검증 절차
1. 위 AC 커맨드를 실행한다.
2. 문서 체크리스트를 확인한다:
- `docs/PRD.md`의 목적, 독자, 범위를 벗어나지 않았는가?
- 외부 사실은 `docs/ResearchNote.md`의 출처와 연결되는가?
- 초안은 `drafts/`, 최종본은 `final/`에 분리되었는가?
- 사용자 피드백 파일을 삭제하거나 덮어쓰지 않았는가?
3. 결과에 따라 `phases/{task-name}/index.json`의 해당 step을 업데이트한다:
- 성공 -> `"status": "completed"`, `"summary": "산출물 한 줄 요약"`
- 수정 3회 시도 후에도 실패 -> `"status": "error"`, `"error_message": "구체적 에러 내용"`
- 사용자 개입 필요 -> `"status": "blocked"`, `"blocked_reason": "구체적 요청 사항"` 후 즉시 중단
## 금지사항
- PRD에 없는 문서 목표를 추가하지 마라. 이유: 사용자 의도가 흐려진다.
- 출처 없는 외부 사실을 최종 문서에 단정하지 마라. 이유: 검증 가능성이 사라진다.
- 초안 파일을 최종본으로 덮어쓰지 마라. 이유: 피드백 전후 변경 추적이 어렵다.
- 사용자 피드백 파일을 삭제하지 마라. 이유: 의사결정 기록이 사라진다.
- 직접 `git commit`하지 마라. 이유: `scripts/execute.py`가 step 완료 후 커밋을 관리한다.
```
@@ -0,0 +1,45 @@
---
name: "document-review"
description: "Use when reviewing Markdown document changes for PRD alignment, source traceability, feedback coverage, structure, and final delivery readiness."
---
# Document Review Skill
Use this skill to review changed Markdown files in the Codex Markdown Document Harness.
## Read First
- `AGENTS.md`
- `docs/PRD.md`
- `docs/ResearchNote.md`
- `docs/DraftFeedback.md`
- `docs/FinalFeedback.md`
- `docs/ARCHITECTURE.md`
- `docs/ADR.md`
- `docs/UI_GUIDE.md`
## Review Checklist
1. PRD alignment: purpose, target reader, deliverables, scope, and tone match `docs/PRD.md`.
2. Source traceability: external facts, dates, statistics, claims, and quotations connect to `docs/ResearchNote.md`.
3. Structure: heading hierarchy, section order, and file names match the intended deliverables.
4. Feedback coverage: `docs/DraftFeedback.md` or `docs/FinalFeedback.md` requests are addressed.
5. Draft/final separation: drafts live under `drafts/`; final deliverables live under `final/`.
6. Style quality: avoid generic AI prose, unsupported superlatives, repetition, and vague claims.
7. Validation: `python scripts/validate_docs.py` passes.
## Output Format
Lead with findings. Use this table when a full checklist result is useful:
| 항목 | 결과 | 비고 |
|------|------|------|
| PRD 정합성 | PASS/FAIL | {상세} |
| 출처 추적 | PASS/FAIL | {상세} |
| 문서 구조 | PASS/FAIL | {상세} |
| 피드백 반영 | PASS/FAIL | {상세} |
| 초안/최종본 분리 | PASS/FAIL | {상세} |
| 문체 품질 | PASS/FAIL | {상세} |
| 검증 가능성 | PASS/FAIL | {상세} |
If there are issues, include concrete file paths and suggested fixes.
@@ -0,0 +1,16 @@
name = "doc_drafter"
description = "Turns PRD requirements and ResearchNote evidence into reviewable Markdown drafts."
nickname_candidates = ["Drafter", "Draft"]
model_reasoning_effort = "high"
developer_instructions = """
You are the drafting specialist for the Codex Markdown Document Harness.
Responsibilities:
- Read AGENTS.md, docs/PRD.md, docs/ResearchNote.md, and docs/UI_GUIDE.md before drafting.
- Create draft documents only under drafts/.
- Keep the document goal, audience, scope, and tone aligned with docs/PRD.md.
- Tie external claims to docs/ResearchNote.md sources.
- Preserve user feedback files and do not overwrite final/ documents.
- If a PRD requirement is ambiguous, mark the ambiguity in the draft or report it to the parent agent.
"""
@@ -0,0 +1,15 @@
name = "doc_researcher"
description = "Researches PRD keywords, gathers trustworthy sources, and maintains docs/ResearchNote.md."
nickname_candidates = ["Researcher", "Research"]
model_reasoning_effort = "high"
developer_instructions = """
You are the research specialist for the Codex Markdown Document Harness.
Responsibilities:
- Read AGENTS.md, docs/PRD.md, and docs/UI_GUIDE.md before researching.
- Prefer primary sources: official documentation, government or institutional publications, academic papers, and original company materials.
- Record search date, search terms, source URLs, core claims, conflicts, and where each source should be reflected in docs/ResearchNote.md.
- Mark uncertain claims as 확인 필요 instead of presenting them as facts.
- Do not write final prose in final/. Your primary output is docs/ResearchNote.md and concise research notes for the parent agent.
"""
@@ -0,0 +1,14 @@
name = "doc_reviewer"
description = "Reviews Markdown documents for PRD alignment, evidence quality, structure, and feedback coverage."
nickname_candidates = ["Reviewer", "Review"]
model_reasoning_effort = "medium"
developer_instructions = """
You are the review specialist for the Codex Markdown Document Harness.
Responsibilities:
- Review changed Markdown files against AGENTS.md, docs/PRD.md, docs/ResearchNote.md, docs/DraftFeedback.md, docs/FinalFeedback.md, and docs/UI_GUIDE.md.
- Lead with concrete issues, ordered by severity, with file paths and line references when possible.
- Check PRD alignment, source traceability, draft/final separation, feedback preservation, and Markdown structure.
- Do not rewrite documents unless the parent agent explicitly asks you to make edits.
"""
@@ -0,0 +1,14 @@
name = "evidence_checker"
description = "Checks whether factual claims in drafts and final documents are supported by ResearchNote sources."
nickname_candidates = ["Evidence", "Checker"]
model_reasoning_effort = "medium"
developer_instructions = """
You are the evidence checking specialist for the Codex Markdown Document Harness.
Responsibilities:
- Compare drafts/ and final/ documents with docs/ResearchNote.md.
- Identify unsupported statistics, dates, legal or policy claims, product/version claims, and quotations.
- Report missing, weak, stale, or conflicting evidence.
- Prefer concise claim-to-source mapping over broad style feedback.
"""
+10
View File
@@ -0,0 +1,10 @@
web_search = "live"
[features]
codex_hooks = true
multi_agent = true
[agents]
max_threads = 4
max_depth = 1
job_max_runtime_seconds = 1800
+30
View File
@@ -0,0 +1,30 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|shell_command",
"hooks": [
{
"type": "command",
"command": "python .codex/hooks/pre_tool_guard.py",
"timeout": 10,
"statusMessage": "Checking shell command safety"
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python .codex/hooks/stop_validate.py",
"timeout": 60,
"statusMessage": "Validating document harness files"
}
]
}
]
}
}
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Codex PreToolUse guard for obviously destructive shell commands."""
import json
import re
import sys
from typing import Any
DANGEROUS_PATTERNS = [
(r"\brm\s+-rf\b", "Recursive force deletion is blocked by the document harness."),
(
r"\bRemove-Item\b(?=.*\b-Recurse\b|\s-r\b)(?=.*\b-Force\b|\s-f\b)",
"PowerShell recursive force deletion is blocked by the document harness.",
),
(r"\bgit\s+reset\s+--hard\b", "Hard reset is blocked because it can discard user work."),
(r"\bgit\s+push\b.*\s--force(?:-with-lease)?\b", "Force push is blocked by the document harness."),
(r"\bDROP\s+TABLE\b", "Destructive database commands are blocked by the document harness."),
]
def iter_strings(value: Any):
if isinstance(value, str):
yield value
elif isinstance(value, dict):
for key, item in value.items():
yield str(key)
yield from iter_strings(item)
elif isinstance(value, list):
for item in value:
yield from iter_strings(item)
def deny(reason: str) -> None:
payload = {
"hookSpecificOutput": {
"permissionDecision": "deny",
"permissionDecisionReason": reason,
},
"decision": "block",
"reason": reason,
}
print(json.dumps(payload, ensure_ascii=False))
def main() -> int:
raw = sys.stdin.read()
haystack = raw
try:
data = json.loads(raw) if raw.strip() else {}
except json.JSONDecodeError:
data = {}
if data:
haystack += "\n" + "\n".join(iter_strings(data))
for pattern, reason in DANGEROUS_PATTERNS:
if re.search(pattern, haystack, flags=re.IGNORECASE | re.DOTALL):
deny(reason)
return 0
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Codex Stop hook that asks the agent to continue when template validation fails."""
import json
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def main() -> int:
result = subprocess.run(
[sys.executable, "scripts/validate_docs.py"],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if result.returncode == 0:
return 0
details = "\n".join(part for part in [result.stdout.strip(), result.stderr.strip()] if part)
payload = {
"decision": "block",
"reason": (
"Document harness validation failed. Continue the turn, fix the listed "
f"issues, and run `python scripts/validate_docs.py` again.\n\n{details}"
),
}
print(json.dumps(payload, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
+14
View File
@@ -0,0 +1,14 @@
node_modules/
.next/
out/
next-env.d.ts
tsconfig.tsbuildinfo
# Python/test cache
__pycache__/
*.pyc
.pytest_cache/
# phase execution outputs
phases/**/phase*-output.json
phases/**/step*-output.json
+57
View File
@@ -0,0 +1,57 @@
# 프로젝트: Codex Markdown Document Harness Template
## 목적
이 템플릿은 Codex와 Harness Engineering 방식을 이용해 사용자의 목표에 맞는 Markdown 문서를 단계적으로 작성하기 위한 작업 환경이다.
사용자는 `docs/PRD.md`에 문서의 목적, 개요, 대상 독자, 중요 키워드, 참고 자료를 입력한다. 이후 Codex는 이 정보를 기준으로 작성 규칙을 구체화하고, 조사 노트, 초안, 피드백 반영본, 최종 문서를 순차적으로 만든다.
## Codex 구성
- `AGENTS.md`: Codex가 항상 참고하는 프로젝트 규칙.
- `.agents/skills/document-harness/`: 문서 작성 Harness의 단계별 실행 절차.
- `.agents/skills/document-review/`: 문서 변경 사항 리뷰 절차.
- `.codex/agents/`: 조사, 초안, 리뷰에 특화된 Codex custom agents.
- `.codex/hooks.json`: 문서 검증과 위험 명령 방지를 위한 lifecycle hooks.
- `.codex/config.toml`: hooks, multi-agent, live web search 등 이 템플릿에서 권장하는 Codex 기능 설정.
## 기본 산출물
- `docs/PRD.md`: 사용자가 작성하는 문서 요구사항의 원천.
- `docs/ResearchNote.md`: 웹 조사 결과, 출처, 쟁점, 문서 반영 메모.
- `drafts/`: 사용자 검토를 위한 초안 문서.
- `final/`: 피드백을 반영한 최종 문서.
- `docs/DraftFeedback.md`: 초안 검토 후 사용자가 남기는 피드백.
- `docs/FinalFeedback.md`: 최종 문서 검토 후 사용자가 남기는 피드백.
- `phases/`: Harness step 실행 계획과 상태 파일.
## 문서 작성 규칙
- CRITICAL: `docs/PRD.md`를 단일 요구사항 원천으로 삼는다. PRD에 없는 목표, 독자, 범위, 톤을 임의로 추가하지 마라.
- CRITICAL: 외부 사실, 통계, 최신 정보, 인용, 법/제도/가격/제품 정보는 `docs/ResearchNote.md`의 출처에 근거해야 한다.
- CRITICAL: 출처가 불명확한 주장을 최종 문서에 단정적으로 쓰지 마라. 필요한 경우 "확인 필요" 또는 "출처 필요"로 표시한다.
- CRITICAL: 초안 작성 후와 최종 문서 작성 후에는 사용자 피드백을 받아야 한다. 피드백이 필요한 step은 `blocked` 상태와 구체적인 `blocked_reason`을 기록한다.
- CRITICAL: 최종 문서는 초안과 분리해 `final/` 아래에 작성한다. 초안 파일을 최종본처럼 덮어쓰지 마라.
- 조사 노트에는 검색 일시, 검색어, 출처 URL, 핵심 요지, 문서 반영 여부를 남긴다.
- 문서 구조는 제목 계층을 유지한다. `#`는 문서 제목에만 사용하고, 본문 구조는 `##`, `###`를 사용한다.
- 사용자의 피드백은 삭제하지 말고 별도 피드백 문서에 보존한다.
## Codex 작업 규칙
- 반복 가능한 절차는 `.agents/skills/`의 Skill에 둔다. `AGENTS.md`에는 지속적으로 적용할 짧은 규칙만 유지한다.
- 문서 작성 Harness를 실행하거나 설계할 때는 `$document-harness` Skill을 우선 사용한다.
- 문서 변경 사항을 검토할 때는 `$document-review` Skill을 사용한다.
- 병렬 조사나 독립 리뷰가 필요한 경우 `.codex/agents/`의 custom agents를 명시적으로 선택한다.
- `scripts/execute.py`가 step 실행 후 git commit을 처리하므로, step을 수행하는 Codex 세션은 직접 commit하지 않는다.
## 권장 워크플로우
1. 사용자가 `docs/PRD.md`를 채운다.
2. Codex가 PRD를 읽고 `AGENTS.md`의 프로젝트별 작성 규칙을 구체화한다.
3. Codex 또는 Codex subagents가 웹 검색을 수행하고 `docs/ResearchNote.md`를 작성한다.
4. Codex가 `drafts/`에 초안을 만들고 사용자 검토를 요청한다.
5. 사용자가 `docs/DraftFeedback.md`에 피드백을 남긴다.
6. Codex가 피드백을 반영해 `final/`에 최종 문서를 작성한다.
7. 사용자가 `docs/FinalFeedback.md`에 최종 피드백 또는 승인 여부를 남긴다.
## 명령어
```bash
python scripts/validate_docs.py
python scripts/execute.py <task-name>
python scripts/execute.py <task-name> --push
python -m pytest scripts/test_execute.py
```
+259
View File
@@ -0,0 +1,259 @@
# Codex Markdown Document Harness Template
Codex 환경에서 Harness Engineering 방식으로 Markdown 문서를 단계적으로 작성하기 위한 템플릿입니다.
사용자는 `docs/PRD.md`에 만들고 싶은 문서의 목적, 대상 독자, 개요, 중요 키워드, 조사 요구사항을 작성합니다. 이후 Codex는 PRD를 기준으로 작성 규칙을 구체화하고, 웹 조사, 조사 노트, 초안, 사용자 피드백, 최종 문서를 순서대로 만들어 갑니다.
## 핵심 아이디어
이 템플릿은 한 번에 최종 문서를 쓰는 방식이 아니라, 다음 흐름을 강제합니다.
```text
PRD 작성
-> 작성 규칙 구체화
-> 웹 조사 및 ResearchNote 작성
-> drafts/ 초안 작성
-> 사용자 초안 피드백
-> final/ 최종 문서 작성
-> 사용자 최종 피드백 또는 승인
```
목표는 빠른 초안 작성보다 사용자의 의도, 출처, 피드백, 최종 산출물을 분리해 관리하는 것입니다.
## Codex 구성
```text
.
├── AGENTS.md # Codex가 읽는 프로젝트 기본 규칙
├── .agents/
│ └── skills/
│ ├── document-harness/ # 단계적 문서 작성 Skill
│ └── document-review/ # 문서 리뷰 Skill
├── .codex/
│ ├── config.toml # hooks, multi-agent, live web search 설정
│ ├── hooks.json # Stop/PreToolUse hook 연결
│ ├── hooks/ # hook 실행 스크립트
│ └── agents/ # 조사, 초안, 리뷰, 근거 점검 custom agents
├── docs/
│ ├── PRD.md # 사용자가 채우는 문서 요구사항
│ ├── ResearchNote.md # 조사 결과와 출처 장부
│ ├── DraftFeedback.md # 초안 피드백
│ ├── FinalFeedback.md # 최종 문서 피드백
│ ├── ARCHITECTURE.md # 템플릿 구조 설명
│ ├── ADR.md # 주요 설계 결정
│ └── UI_GUIDE.md # Markdown 문서 스타일 가이드
├── drafts/ # 초안 문서
├── final/ # 최종 문서
├── phases/ # 단계 실행 계획과 상태 파일
└── scripts/
├── execute.py # codex exec 기반 step 실행기
├── validate_docs.py # 템플릿 구조 검증
└── test_execute.py # 실행기 테스트
```
## 빠른 시작
1. 템플릿을 git 저장소로 준비합니다.
```bash
git init
```
`scripts/execute.py`는 브랜치 생성과 커밋을 수행하므로 자동 실행을 쓰려면 git 저장소가 필요합니다.
2. `docs/PRD.md`를 채웁니다.
최소한 아래 항목은 구체적으로 작성하는 것이 좋습니다.
- 문서 목적
- 대상 독자
- 최종 산출물
- 문서 개요
- 중요 키워드
- 핵심 질문
- 포함할 범위와 제외할 범위
- 톤과 스타일
- 조사 요구사항
- 승인 기준
3. Codex에서 Harness Skill을 사용합니다.
예시 프롬프트:
```text
$document-harness를 사용해서 docs/PRD.md를 읽고 문서 작성 phase를 설계해 주세요.
```
또는 바로 다음처럼 요청할 수 있습니다.
```text
$document-harness를 사용해서 docs/PRD.md 기준으로 작성 규칙 구체화, ResearchNote 작성, 초안 작성 단계까지 진행해 주세요.
```
4. 생성된 초안을 검토합니다.
초안은 `drafts/` 아래에 생성됩니다. 검토 후 `docs/DraftFeedback.md`에 피드백을 작성합니다.
5. 최종본을 검토합니다.
최종 문서는 `final/` 아래에 생성됩니다. 검토 후 `docs/FinalFeedback.md`에 승인 또는 추가 수정 요청을 작성합니다.
## 자동 실행 방식
Codex가 `phases/{task-name}/` 아래에 step 파일을 만든 뒤, 실행기는 각 step을 `codex exec`로 순차 실행합니다.
```bash
python scripts/execute.py <task-name>
```
원격 저장소에 push까지 하려면 다음 명령을 사용합니다.
```bash
python scripts/execute.py <task-name> --push
```
실행기가 처리하는 일:
- `feat-{task-name}` 브랜치 생성 또는 checkout
- `AGENTS.md``docs/*.md`를 매 step 프롬프트에 주입
- 완료된 step의 `summary`를 다음 step에 전달
- 실패 시 최대 3회 재시도
- step 상태를 `completed`, `blocked`, `error`로 관리
- step 완료 후 문서 변경과 메타데이터를 커밋
## 피드백 게이트
사용자 검토가 필요한 단계에서는 step이 `blocked` 상태로 멈출 수 있습니다.
초안 피드백:
```text
docs/DraftFeedback.md
```
최종 피드백:
```text
docs/FinalFeedback.md
```
피드백을 작성한 뒤 해당 step의 상태를 `pending`으로 되돌리고 다시 실행하면 다음 단계가 진행됩니다.
## Codex Skills
이 템플릿은 repo 공유 Skill을 사용합니다.
`document-harness`:
- PRD intake
- 작성 규칙 구체화
- ResearchNote 작성
- 초안 작성
- 피드백 게이트
- 최종 문서 작성
`document-review`:
- PRD 정합성 검토
- 출처 추적 검토
- 초안/최종본 분리 확인
- 피드백 반영 확인
- 문체와 Markdown 구조 검토
Codex에서 명시적으로 호출할 수 있습니다.
```text
$document-harness
$document-review
```
## Codex Custom Agents
`.codex/agents/`에는 문서 작성에 특화된 역할이 정의되어 있습니다.
| Agent | 역할 |
|-------|------|
| `doc_researcher` | PRD 키워드 조사, 출처 수집, `docs/ResearchNote.md` 작성 |
| `doc_drafter` | ResearchNote와 PRD를 바탕으로 `drafts/` 초안 작성 |
| `doc_reviewer` | PRD 정합성, 구조, 피드백 반영 여부 리뷰 |
| `evidence_checker` | 문서 주장과 ResearchNote 출처 연결 확인 |
Codex는 subagent를 항상 자동으로 생성하지 않습니다. 병렬 조사나 독립 리뷰가 필요하면 프롬프트에서 명시적으로 요청하세요.
예시:
```text
doc_researcher와 evidence_checker 역할을 사용해 핵심 키워드를 병렬 조사하고 docs/ResearchNote.md를 정리해 주세요.
```
## Hooks
`.codex/hooks.json`은 두 가지 기본 hook을 연결합니다.
- `PreToolUse`: 위험한 shell 명령을 차단합니다.
- `Stop`: 응답 종료 시 `python scripts/validate_docs.py`를 실행해 템플릿 구조를 검증합니다.
검증 실패 시 Codex가 문제를 고치도록 이어서 작업하게 만드는 용도입니다.
## 검증
템플릿 구조를 확인합니다.
```bash
python scripts/validate_docs.py
```
실행기 테스트를 실행합니다.
```bash
python -m pytest scripts/test_execute.py
```
현재 기대 결과:
```text
Document harness validation passed.
51 passed
```
## 문서 작성 규칙
- `docs/PRD.md`를 단일 요구사항 원천으로 사용합니다.
- 외부 사실, 통계, 최신 정보, 법/제도/가격/제품 정보는 `docs/ResearchNote.md`의 출처에 근거해야 합니다.
- 출처가 불명확한 내용은 최종 문서에 단정적으로 쓰지 않습니다.
- 초안은 `drafts/`, 최종 문서는 `final/`에 분리합니다.
- 사용자 피드백 파일은 삭제하거나 덮어쓰지 않습니다.
- 문서 제목은 `#`, 주요 섹션은 `##`, 하위 섹션은 `###`를 사용합니다.
## 추천 사용 프롬프트
PRD 검토:
```text
$document-harness를 사용해 docs/PRD.md가 문서 작성을 시작하기에 충분한지 검토해 주세요.
```
조사 노트 작성:
```text
$document-harness를 사용해 docs/PRD.md의 중요 키워드를 웹 조사하고 docs/ResearchNote.md를 작성해 주세요.
```
초안 작성:
```text
$document-harness를 사용해 docs/PRD.md와 docs/ResearchNote.md를 바탕으로 drafts/에 초안을 작성해 주세요.
```
문서 리뷰:
```text
$document-review를 사용해 drafts/와 final/의 변경 사항을 검토해 주세요.
```
## 주의사항
- 자동 실행기는 git 저장소를 전제로 합니다.
- 최신 정보가 중요한 문서는 `docs/ResearchNote.md`에 조사 일시와 기준일을 남겨야 합니다.
- `AGENTS.md`에는 지속적으로 적용할 규칙만 두고, 긴 절차는 Skill에 둡니다.
- Claude Code용 `.claude/` 구조는 사용하지 않습니다. 이 템플릿은 Codex의 `AGENTS.md`, Skill, hook, custom agent 구조를 기준으로 합니다.
+48
View File
@@ -0,0 +1,48 @@
# Architecture Decision Records
## 철학
이 템플릿의 핵심 가치는 사용자의 의도를 보존하면서도, 조사와 피드백을 통해 Markdown 문서 품질을 단계적으로 높이는 것이다. 빠르게 초안을 만들되, 근거 없는 최종본을 만들지 않는다.
---
### ADR-001: Markdown-first 문서 산출
**결정**: 모든 중간 산출물과 최종 산출물은 Markdown으로 작성한다.
**이유**: Markdown은 버전 관리, 리뷰, 재사용, 자동 변환에 적합하고 AI Agent가 구조를 안정적으로 다루기 쉽다.
**트레이드오프**: PDF, DOCX, 슬라이드 같은 최종 배포 형식은 별도 변환 단계가 필요하다.
### ADR-002: PRD를 단일 요구사항 원천으로 사용
**결정**: `docs/PRD.md`를 문서 목적, 독자, 범위, 톤, 키워드의 기준으로 삼는다.
**이유**: 단계가 길어질수록 AI Agent가 임의로 목표를 확장할 위험이 있다. 단일 원천을 두면 초안과 최종본을 같은 기준으로 평가할 수 있다.
**트레이드오프**: PRD가 빈약하면 후속 산출물도 흐려진다. 필요한 경우 PRD 보강을 먼저 요청해야 한다.
### ADR-003: ResearchNote를 출처 장부로 사용
**결정**: 웹 조사 결과와 출처 검증은 `docs/ResearchNote.md`에 먼저 정리한 뒤 문서에 반영한다.
**이유**: 최종 문서에서 어떤 주장에 어떤 근거가 사용되었는지 추적할 수 있다.
**트레이드오프**: 짧은 문서라도 조사 단계가 하나 추가된다. 대신 사실 오류와 출처 누락 위험을 줄인다.
### ADR-004: 피드백 지점은 blocked 상태로 표현
**결정**: 사용자 검토가 필요한 step은 `blocked` 상태와 구체적인 `blocked_reason`을 기록한다.
**이유**: Harness 실행기가 사용자 개입이 필요한 지점을 명확히 멈출 수 있다.
**트레이드오프**: 사용자가 피드백을 작성한 뒤 상태를 `pending`으로 되돌려 재실행해야 한다.
### ADR-005: 초안과 최종본 분리
**결정**: 초안은 `drafts/`, 최종본은 `final/`에 저장한다.
**이유**: 사용자 검토 흔적과 최종 납품물을 명확히 분리할 수 있다.
**트레이드오프**: 파일 수가 늘어난다. 대신 리뷰와 회귀 확인이 쉬워진다.
### ADR-006: Codex의 AGENTS/Skill/Hook 구조로 이전
**결정**: Claude 전용 `CLAUDE.md`, `.claude/commands`, `.claude/settings.json` 구조를 Codex의 `AGENTS.md`, `.agents/skills`, `.codex/hooks.json`, `.codex/agents` 구조로 이전한다.
**이유**: Codex는 프로젝트 지침을 `AGENTS.md`로 읽고, 재사용 가능한 워크플로우를 Skill로 관리하며, lifecycle hook과 custom agent를 별도 디렉토리에서 구성한다. 템플릿의 의도를 Codex의 네이티브 구조에 맞추면 실행 맥락과 재사용성이 좋아진다.
**트레이드오프**: Claude Code와의 직접 호환성은 낮아진다. 대신 Codex CLI, Skill, custom agent, hook을 기준으로 한 문서 작성 자동화가 명확해진다.
+74
View File
@@ -0,0 +1,74 @@
# 문서 작성 하네스 아키텍처
## 디렉토리 구조
```text
.
├── AGENTS.md # Codex가 읽는 프로젝트별 문서 작성 규칙
├── .agents/
│ └── skills/
│ ├── document-harness/ # 단계적 문서 작성 Skill
│ └── document-review/ # 문서 리뷰 Skill
├── .codex/
│ ├── config.toml # Codex 기능, live web search, agent 한도 설정
│ ├── hooks.json # Stop/PreToolUse hook 설정
│ ├── hooks/ # hook 실행 스크립트
│ └── agents/ # 조사/초안/리뷰 custom agents
├── docs/
│ ├── PRD.md # 사용자 요구사항 원천
│ ├── ResearchNote.md # 조사 노트와 출처 장부
│ ├── DraftFeedback.md # 초안 피드백
│ ├── FinalFeedback.md # 최종 문서 피드백
│ ├── ARCHITECTURE.md # 하네스 구조
│ ├── ADR.md # 문서 작성 의사결정
│ └── UI_GUIDE.md # Markdown 스타일 가이드
├── drafts/ # 검토용 초안 산출물
├── final/ # 피드백 반영 최종 산출물
├── phases/ # Harness task/step 계획과 상태
└── scripts/
├── execute.py # codex exec 기반 step 순차 실행기
├── validate_docs.py # 문서 템플릿 기본 검증
└── test_execute.py # execute.py 안전망 테스트
```
## 데이터 흐름
```text
사용자 입력
-> docs/PRD.md
-> AGENTS.md 작성 규칙 구체화
-> Codex/custom agents 웹 검색 및 출처 검증
-> docs/ResearchNote.md
-> drafts/ 초안 작성
-> docs/DraftFeedback.md 사용자 피드백
-> final/ 최종 문서 작성
-> docs/FinalFeedback.md 최종 피드백 또는 승인
```
## Step 설계 패턴
권장 phase는 아래 순서를 따른다.
1. `rule-synthesis`: `docs/PRD.md`를 읽고 `AGENTS.md`의 문서 작성 규칙을 프로젝트에 맞게 구체화한다.
2. `research-note`: 웹 검색과 사용자가 제공한 자료를 바탕으로 `docs/ResearchNote.md`를 작성한다. 필요하면 `doc_researcher` agent를 사용한다.
3. `draft-documents`: `drafts/`에 사용자 검토용 초안을 작성한다.
4. `draft-feedback-gate`: `docs/DraftFeedback.md`가 비어 있으면 `blocked`로 멈추고 사용자 검토를 요청한다.
5. `final-documents`: 피드백을 반영해 `final/`에 최종 문서를 작성한다.
6. `final-feedback-gate`: `docs/FinalFeedback.md`에 승인 또는 추가 수정 요청이 없으면 `blocked`로 멈춘다.
## Codex 구성 책임
- `AGENTS.md`는 Codex가 매 작업에서 읽는 짧고 지속적인 규칙을 담는다.
- `.agents/skills/document-harness/`는 phase 생성, research, draft, feedback gate, final 작성 절차를 담는다.
- `.agents/skills/document-review/`는 변경된 Markdown 문서의 리뷰 체크리스트를 담는다.
- `.codex/agents/`는 조사, 초안 작성, 리뷰, 근거 점검 역할을 분리한다.
- `.codex/hooks.json`은 위험 명령 차단과 Stop 시점 문서 검증을 연결한다.
## 상태 관리
- `pending`: 아직 실행되지 않은 step.
- `completed`: step 산출물이 생성되었고 검증이 끝난 상태.
- `blocked`: 사용자 피드백, 자료 제공, 승인 등 외부 입력이 필요한 상태.
- `error`: 자동 수정 3회 후에도 실패한 상태.
## 파일 책임
- `docs/PRD.md`는 사용자의 의도와 요구사항을 보존한다. Codex가 임의로 요구사항을 바꾸지 않는다.
- `docs/ResearchNote.md`는 사실 검증의 근거 장부다. 최종 문서의 외부 주장은 이 파일의 출처와 연결되어야 한다.
- `drafts/`는 논의용이다. 문장이 거칠 수 있지만 구조와 근거는 검토 가능해야 한다.
- `final/`은 납품용이다. 사용자 피드백, 출처, 스타일 기준을 반영해야 한다.
- `phases/``index.json``stepN.md`는 독립 실행 가능한 작업 지시서다.
+23
View File
@@ -0,0 +1,23 @@
# Draft Feedback
초안 검토 후 사용자가 피드백을 남기는 파일이다. AI Agent는 이 파일을 읽고 `final/` 문서에 반영한다.
## 검토 대상
- `drafts/{파일명}`
## 전체 판단
- {예: 방향 승인 / 구조 수정 필요 / 추가 조사 필요 / 톤 변경 필요}
## 수정 요청
| 위치 | 요청 | 이유 |
|------|------|------|
| {섹션 또는 파일명} | {수정 요청} | {왜 필요한지} |
## 추가로 포함할 내용
- {추가 내용}
## 제외하거나 줄일 내용
- {삭제/축소할 내용}
## 승인 여부
{예: 초안 방향 승인 / 아직 승인하지 않음}
+17
View File
@@ -0,0 +1,17 @@
# Final Feedback
최종 문서 검토 후 사용자가 승인 또는 추가 수정 요청을 남기는 파일이다.
## 검토 대상
- `final/{파일명}`
## 승인 여부
{예: 승인 / 수정 후 승인 / 승인하지 않음}
## 최종 수정 요청
| 위치 | 요청 | 우선순위 |
|------|------|----------|
| {섹션 또는 파일명} | {수정 요청} | {높음/중간/낮음} |
## 비고
- {추가 의견}
+68
View File
@@ -0,0 +1,68 @@
# PRD: {문서 프로젝트명}
이 파일은 Codex 문서 작성 Harness의 출발점이다. 사용자는 아래 항목을 가능한 한 구체적으로 채운다. Codex는 이 문서를 기준으로 작성 규칙, 조사 계획, 초안, 최종 문서를 만든다.
## 문서 목적
{이 문서가 해결하려는 문제, 설득하려는 주장, 설명하려는 주제, 또는 독자가 얻어야 할 결과를 한 문단으로 작성}
## 대상 독자
- 주요 독자: {예: 경영진, 개발자, 학생, 고객, 정책 담당자}
- 독자의 배경지식: {초급/중급/전문가, 알고 있다고 가정해도 되는 것}
- 독자가 문서를 읽은 뒤 해야 할 행동: {결정, 학습, 실행, 검토, 공유 등}
## 최종 산출물
| 문서명 | 목적 | 예상 분량 | 필수 포함 요소 |
|--------|------|-----------|----------------|
| {예: executive-summary.md} | {요약/설득/보고} | {예: 2쪽} | {핵심 메시지, 근거, 권고안} |
| {예: full-report.md} | {상세 설명} | {예: 10쪽} | {배경, 분석, 결론, 참고문헌} |
## 문서 개요
{원하는 목차, 포함해야 할 흐름, 반드시 다뤄야 할 섹션을 작성}
## 중요 키워드
- {키워드 1}
- {키워드 2}
- {키워드 3}
## 핵심 질문
- {문서가 반드시 답해야 하는 질문 1}
- {문서가 반드시 답해야 하는 질문 2}
- {문서가 반드시 답해야 하는 질문 3}
## 범위
### 포함할 것
- {포함 범위 1}
- {포함 범위 2}
### 제외할 것
- {제외 범위 1}
- {제외 범위 2}
## 톤과 스타일
- 톤: {예: 전문적, 차분한 보고서, 친근한 설명문, 강한 설득형}
- 언어: {예: 한국어, 영어, 한영 병기}
- 문체: {예: 간결한 문장, 긴 분석형 문단, bullet 중심}
- 금지 표현: {예: 과장 광고 문구, "혁신적인", "압도적인" 같은 근거 없는 표현}
## 참고 자료
사용자가 이미 가진 자료나 반드시 참고해야 할 링크를 적는다.
| 제목 | URL 또는 파일 경로 | 참고 이유 |
|------|-------------------|-----------|
| {자료명} | {URL/path} | {왜 중요한지} |
## 조사 요구사항
- 검색해야 할 주제: {예: 시장 규모, 기술 동향, 경쟁 사례, 법적 요건}
- 선호 출처: {예: 공식 문서, 학술 논문, 정부/기관 자료, 기업 보고서}
- 피해야 할 출처: {예: 출처 불명 블로그, 홍보성 기사}
- 최신성 기준: {예: 최근 2년, 2025년 이후, 최신 버전}
## 품질 기준
- {예: 모든 핵심 주장에 출처를 달 것}
- {예: 결론 전에 대안과 반론을 함께 검토할 것}
- {예: 초안은 빠르게, 최종본은 문장 품질과 일관성을 엄격히 볼 것}
## 사용자 피드백 방식
- 초안 피드백 위치: `docs/DraftFeedback.md`
- 최종 피드백 위치: `docs/FinalFeedback.md`
- 승인 기준: {예: 사용자가 명시적으로 "승인"이라고 남기면 완료}
+54
View File
@@ -0,0 +1,54 @@
# Research Note: {문서 프로젝트명}
이 파일은 조사 내용과 출처를 보존하는 장부다. 최종 문서에 들어가는 외부 사실, 통계, 인용, 사례는 가능한 한 이 파일의 항목과 연결되어야 한다.
## 조사 범위
- 기준 PRD: `docs/PRD.md`
- 조사 주제: {조사할 주제}
- 제외 주제: {조사하지 않을 주제}
- 최신성 기준: {예: 최근 2년, 2025년 이후, 최신 공식 문서}
## 조사 일시
- 시작: {YYYY-MM-DD HH:mm, timezone}
- 종료: {YYYY-MM-DD HH:mm, timezone}
- 조사자: AI Agent
## 검색어
| 검색어 | 목적 | 결과 메모 |
|--------|------|-----------|
| {검색어} | {무엇을 확인하려 했는지} | {핵심 결과} |
## 핵심 결론
1. {조사에서 확인한 핵심 결론}
2. {조사에서 확인한 핵심 결론}
3. {조사에서 확인한 핵심 결론}
## 출처 목록
| ID | 제목 | URL | 게시일/확인일 | 신뢰도 | 관련 키워드 |
|----|------|-----|---------------|--------|-------------|
| S1 | {출처 제목} | {URL} | {날짜} | {공식/학술/언론/블로그 등} | {키워드} |
## 출처별 메모
### S1: {출처 제목}
- URL: {URL}
- 요지: {핵심 내용 요약}
- 문서에 쓸 수 있는 내용: {반영할 사실/사례/근거}
- 주의사항: {한계, 편향, 오래된 정보, 상충 자료}
## 키워드별 정리
### {키워드}
- 확인된 사실: {내용}
- 관련 출처: {S1, S2}
- 문서 반영 위치: {draft/final의 예상 섹션}
## 쟁점과 상반된 주장
| 쟁점 | 주장 A | 주장 B | 판단/처리 |
|------|--------|--------|-----------|
| {쟁점} | {내용과 출처} | {내용과 출처} | {문서에서 어떻게 다룰지} |
## 확인 필요
- {추가 확인이 필요한 사실}
- {사용자에게 물어봐야 할 내용}
## 문서 반영 메모
- {어떤 결론을 어떤 문서/섹션에 반영할지}
+54
View File
@@ -0,0 +1,54 @@
# Markdown 문서 스타일 가이드
## 원칙
1. 독자의 다음 행동이 분명해야 한다. 설명문이라면 이해, 보고서라면 판단, 가이드라면 실행이 가능해야 한다.
2. 근거와 의견을 섞지 않는다. 사실, 해석, 권고를 구분해 쓴다.
3. AI가 쓴 듯한 일반론보다 사용자의 목적과 키워드에 맞춘 구체성을 우선한다.
## AI 문서 안티패턴
| 금지 사항 | 이유 |
|-----------|------|
| "오늘날 빠르게 변화하는 시대에" 같은 상투적 도입 | 정보 밀도가 낮고 AI 생성문처럼 보인다 |
| 근거 없는 최상급 표현 | 신뢰를 떨어뜨린다 |
| 출처 없는 통계와 수치 | 검증할 수 없다 |
| 같은 의미의 문장을 반복해 분량 늘리기 | 독자의 시간을 낭비한다 |
| 목차와 본문 제목 불일치 | 리뷰와 유지보수가 어려워진다 |
| PRD에 없는 독자나 목표 추가 | 사용자 의도를 벗어난다 |
| ResearchNote에 없는 외부 주장 단정 | 출처 추적이 끊긴다 |
| AGENTS.md와 Skill 지침 불일치 | Codex 실행 맥락이 흔들린다 |
## 구조
- 문서 제목은 `#` 하나만 사용한다.
- 주요 섹션은 `##`, 하위 섹션은 `###`를 사용한다.
- 한 섹션에는 하나의 중심 메시지만 둔다.
- 긴 목록은 표로 바꿀 수 있는지 검토한다.
- 결론 문서라면 "요약 -> 근거 -> 판단/권고 -> 한계" 순서를 우선 고려한다.
- 설명 문서라면 "맥락 -> 핵심 개념 -> 절차/예시 -> 주의사항" 순서를 우선 고려한다.
## 문체
- 문장은 가능한 한 짧게 쓴다.
- 모호한 주어를 피한다.
- "중요하다", "효과적이다"처럼 평가를 쓸 때는 이유나 근거를 바로 붙인다.
- 불확실한 정보는 확률적 표현 또는 확인 필요 표시를 사용한다.
- 한국어 문서에서는 불필요한 영어 약어를 피하고, 처음 등장할 때 풀어쓴다.
## 출처 표기
- 외부 사실은 문장 끝이나 문단 끝에 출처 링크를 붙인다.
- 긴 직접 인용보다 요약과 해석을 우선한다.
- 같은 출처를 반복해서 사용할 때도 어떤 주장에 연결되는지 분명히 한다.
- 출처가 상충하면 `docs/ResearchNote.md`의 "쟁점/상반된 주장"에 기록한다.
## 표와 목록
- 비교, 분류, 의사결정 기준은 표를 우선 검토한다.
- 순서가 중요한 절차는 번호 목록을 사용한다.
- 단순 나열은 bullet을 사용한다.
- 표는 너무 넓어지면 섹션을 나누거나 요약 표와 상세 설명을 분리한다.
## 최종 점검
- PRD의 목적과 대상 독자에 맞는가?
- 모든 핵심 질문에 답했는가?
- 외부 주장에 출처가 있는가?
- 초안 피드백이 반영되었는가?
- 문서 제목, 섹션 제목, 파일명이 산출물 목적과 맞는가?
- 최종 문서는 `final/` 아래에 있는가?
- Codex Skill, agent, hook 지침과 충돌하지 않는가?
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+426
View File
@@ -0,0 +1,426 @@
#!/usr/bin/env python3
"""
Codex Harness Step Executor phase step을 순차 실행하고 자가 교정한다.
Usage:
python3 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들을 Codex로 순차 실행하는 하네스."""
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:
retry_section = ""
if prev_error:
retry_section = (
f"\n## ⚠ 이전 시도 실패 — 아래 에러를 반드시 참고하여 수정하라\n\n"
f"{prev_error}\n\n---\n\n"
)
return (
f"당신은 {self._project} 프로젝트의 Codex 문서 작성 에이전트입니다. 아래 step을 수행하세요.\n\n"
f"{guardrails}\n\n---\n\n"
f"{step_context}{retry_section}"
f"## 작업 규칙\n\n"
f"1. 이전 step에서 작성된 문서와 메모를 확인하고 일관성을 유지하라.\n"
f"2. 이 step에 명시된 작업만 수행하라. 추가 산출물이나 임의 요구사항을 만들지 마라.\n"
f"3. 기존 문서 구조와 피드백 기록을 깨뜨리지 마라.\n"
f"4. AC(Acceptance Criteria) 검증을 직접 실행하라.\n"
f"5. /phases/{self._phase_dir_name}/index.json의 해당 step status를 업데이트하라:\n"
f" - AC 통과 → \"completed\" + \"summary\" 필드에 이 step의 산출물을 한 줄로 요약\n"
f" - {self.MAX_RETRIES}회 수정 시도 후에도 실패 → \"error\" + \"error_message\" 기록\n"
f" - 사용자 개입이 필요한 경우 (API 키, 인증, 수동 설정 등) → \"blocked\" + \"blocked_reason\" 기록 후 즉시 중단\n"
f"6. 직접 git commit하지 마라. commit은 scripts/execute.py가 step 완료 후 수행한다.\n"
f"7. 병렬 조사나 독립 리뷰가 필요하고 step에서 허용했다면 .codex/agents의 custom agent 역할을 활용하라.\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")
cmd = ["codex", "exec", "--skip-git-repo-check", "--full-auto", "--json", "-"]
try:
result = subprocess.run(
cmd,
cwd=self._root,
input=prompt,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=1800,
)
except FileNotFoundError:
print("\n ERROR: Codex CLI를 찾을 수 없습니다. `codex --version`이 실행되는지 확인하세요.")
sys.exit(1)
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" Codex 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="Codex 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()
+560
View File
@@ -0,0 +1,560 @@
"""
execute.py 리팩터링 안전망 테스트.
리팩터링 전후 동작이 동일한지 검증한다.
"""
import json
import os
import subprocess
import sys
import textwrap
from datetime import datetime, timezone, timedelta
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
sys.path.insert(0, str(Path(__file__).parent))
import execute as ex
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_project(tmp_path):
"""phases/, AGENTS.md, docs/ 를 갖춘 임시 프로젝트 구조."""
phases_dir = tmp_path / "phases"
phases_dir.mkdir()
agents_md = tmp_path / "AGENTS.md"
agents_md.write_text("# Rules\n- rule one\n- rule two", encoding="utf-8")
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
(docs_dir / "arch.md").write_text("# Architecture\nSome content", encoding="utf-8")
(docs_dir / "guide.md").write_text("# Guide\nAnother doc", encoding="utf-8")
return tmp_path
@pytest.fixture
def phase_dir(tmp_project):
"""step 3개를 가진 phase 디렉토리."""
d = tmp_project / "phases" / "0-mvp"
d.mkdir()
index = {
"project": "TestProject",
"phase": "mvp",
"steps": [
{"step": 0, "name": "setup", "status": "completed", "summary": "프로젝트 초기화 완료"},
{"step": 1, "name": "core", "status": "completed", "summary": "핵심 로직 구현"},
{"step": 2, "name": "ui", "status": "pending"},
],
}
(d / "index.json").write_text(json.dumps(index, indent=2, ensure_ascii=False), encoding="utf-8")
(d / "step2.md").write_text("# Step 2: UI\n\nUI를 구현하세요.", encoding="utf-8")
return d
@pytest.fixture
def top_index(tmp_project):
"""phases/index.json (top-level)."""
top = {
"phases": [
{"dir": "0-mvp", "status": "pending"},
{"dir": "1-polish", "status": "pending"},
]
}
p = tmp_project / "phases" / "index.json"
p.write_text(json.dumps(top, indent=2), encoding="utf-8")
return p
@pytest.fixture
def executor(tmp_project, phase_dir):
"""테스트용 StepExecutor 인스턴스. git 호출은 별도 mock 필요."""
with patch.object(ex, "ROOT", tmp_project):
inst = ex.StepExecutor("0-mvp")
# 내부 경로를 tmp_project 기준으로 재설정
inst._root = str(tmp_project)
inst._phases_dir = tmp_project / "phases"
inst._phase_dir = phase_dir
inst._phase_dir_name = "0-mvp"
inst._index_file = phase_dir / "index.json"
inst._top_index_file = tmp_project / "phases" / "index.json"
return inst
# ---------------------------------------------------------------------------
# _stamp (= 이전 now_iso)
# ---------------------------------------------------------------------------
class TestStamp:
def test_returns_kst_timestamp(self, executor):
result = executor._stamp()
assert "+0900" in result
def test_format_is_iso(self, executor):
result = executor._stamp()
dt = datetime.strptime(result, "%Y-%m-%dT%H:%M:%S%z")
assert dt.tzinfo is not None
def test_is_current_time(self, executor):
before = datetime.now(ex.StepExecutor.TZ).replace(microsecond=0)
result = executor._stamp()
after = datetime.now(ex.StepExecutor.TZ).replace(microsecond=0) + timedelta(seconds=1)
parsed = datetime.strptime(result, "%Y-%m-%dT%H:%M:%S%z")
assert before <= parsed <= after
# ---------------------------------------------------------------------------
# _read_json / _write_json
# ---------------------------------------------------------------------------
class TestJsonHelpers:
def test_roundtrip(self, tmp_path):
data = {"key": "", "nested": [1, 2, 3]}
p = tmp_path / "test.json"
ex.StepExecutor._write_json(p, data)
loaded = ex.StepExecutor._read_json(p)
assert loaded == data
def test_save_ensures_ascii_false(self, tmp_path):
p = tmp_path / "test.json"
ex.StepExecutor._write_json(p, {"한글": "테스트"})
raw = p.read_text(encoding="utf-8")
assert "한글" in raw
assert "\\u" not in raw
def test_save_indented(self, tmp_path):
p = tmp_path / "test.json"
ex.StepExecutor._write_json(p, {"a": 1})
raw = p.read_text(encoding="utf-8")
assert "\n" in raw
def test_load_nonexistent_raises(self, tmp_path):
with pytest.raises(FileNotFoundError):
ex.StepExecutor._read_json(tmp_path / "nope.json")
# ---------------------------------------------------------------------------
# _load_guardrails
# ---------------------------------------------------------------------------
class TestLoadGuardrails:
def test_loads_agents_md_and_docs(self, executor, tmp_project):
with patch.object(ex, "ROOT", tmp_project):
result = executor._load_guardrails()
assert "# Rules" in result
assert "rule one" in result
assert "# Architecture" in result
assert "# Guide" in result
def test_sections_separated_by_divider(self, executor, tmp_project):
with patch.object(ex, "ROOT", tmp_project):
result = executor._load_guardrails()
assert "---" in result
def test_docs_sorted_alphabetically(self, executor, tmp_project):
with patch.object(ex, "ROOT", tmp_project):
result = executor._load_guardrails()
arch_pos = result.index("arch")
guide_pos = result.index("guide")
assert arch_pos < guide_pos
def test_no_agents_md(self, executor, tmp_project):
(tmp_project / "AGENTS.md").unlink()
with patch.object(ex, "ROOT", tmp_project):
result = executor._load_guardrails()
assert "AGENTS.md" not in result
assert "Architecture" in result
def test_no_docs_dir(self, executor, tmp_project):
import shutil
shutil.rmtree(tmp_project / "docs")
with patch.object(ex, "ROOT", tmp_project):
result = executor._load_guardrails()
assert "Rules" in result
assert "Architecture" not in result
def test_empty_project(self, tmp_path):
with patch.object(ex, "ROOT", tmp_path):
# executor가 필요 없는 static-like 동작이므로 임시 인스턴스
phases_dir = tmp_path / "phases" / "dummy"
phases_dir.mkdir(parents=True)
idx = {"project": "T", "phase": "t", "steps": []}
(phases_dir / "index.json").write_text(json.dumps(idx), encoding="utf-8")
inst = ex.StepExecutor.__new__(ex.StepExecutor)
result = inst._load_guardrails()
assert result == ""
# ---------------------------------------------------------------------------
# _build_step_context
# ---------------------------------------------------------------------------
class TestBuildStepContext:
def test_includes_completed_with_summary(self, phase_dir):
index = json.loads((phase_dir / "index.json").read_text(encoding="utf-8"))
result = ex.StepExecutor._build_step_context(index)
assert "Step 0 (setup): 프로젝트 초기화 완료" in result
assert "Step 1 (core): 핵심 로직 구현" in result
def test_excludes_pending(self, phase_dir):
index = json.loads((phase_dir / "index.json").read_text(encoding="utf-8"))
result = ex.StepExecutor._build_step_context(index)
assert "ui" not in result
def test_excludes_completed_without_summary(self, phase_dir):
index = json.loads((phase_dir / "index.json").read_text(encoding="utf-8"))
del index["steps"][0]["summary"]
result = ex.StepExecutor._build_step_context(index)
assert "setup" not in result
assert "core" in result
def test_empty_when_no_completed(self):
index = {"steps": [{"step": 0, "name": "a", "status": "pending"}]}
result = ex.StepExecutor._build_step_context(index)
assert result == ""
def test_has_header(self, phase_dir):
index = json.loads((phase_dir / "index.json").read_text(encoding="utf-8"))
result = ex.StepExecutor._build_step_context(index)
assert result.startswith("## 이전 Step 산출물")
# ---------------------------------------------------------------------------
# _build_preamble
# ---------------------------------------------------------------------------
class TestBuildPreamble:
def test_includes_project_name(self, executor):
result = executor._build_preamble("", "")
assert "TestProject" in result
def test_includes_guardrails(self, executor):
result = executor._build_preamble("GUARD_CONTENT", "")
assert "GUARD_CONTENT" in result
def test_includes_step_context(self, executor):
ctx = "## 이전 Step 산출물\n\n- Step 0: done"
result = executor._build_preamble("", ctx)
assert "이전 Step 산출물" in result
def test_tells_agent_not_to_commit_directly(self, executor):
result = executor._build_preamble("", "")
assert "직접 git commit하지 마라" in result
def test_includes_rules(self, executor):
result = executor._build_preamble("", "")
assert "작업 규칙" in result
assert "AC" in result
def test_no_retry_section_by_default(self, executor):
result = executor._build_preamble("", "")
assert "이전 시도 실패" not in result
def test_retry_section_with_prev_error(self, executor):
result = executor._build_preamble("", "", prev_error="타입 에러 발생")
assert "이전 시도 실패" in result
assert "타입 에러 발생" in result
def test_includes_max_retries(self, executor):
result = executor._build_preamble("", "")
assert str(ex.StepExecutor.MAX_RETRIES) in result
def test_includes_index_path(self, executor):
result = executor._build_preamble("", "")
assert "/phases/0-mvp/index.json" in result
# ---------------------------------------------------------------------------
# _update_top_index
# ---------------------------------------------------------------------------
class TestUpdateTopIndex:
def test_completed(self, executor, top_index):
executor._top_index_file = top_index
executor._update_top_index("completed")
data = json.loads(top_index.read_text(encoding="utf-8"))
mvp = next(p for p in data["phases"] if p["dir"] == "0-mvp")
assert mvp["status"] == "completed"
assert "completed_at" in mvp
def test_error(self, executor, top_index):
executor._top_index_file = top_index
executor._update_top_index("error")
data = json.loads(top_index.read_text(encoding="utf-8"))
mvp = next(p for p in data["phases"] if p["dir"] == "0-mvp")
assert mvp["status"] == "error"
assert "failed_at" in mvp
def test_blocked(self, executor, top_index):
executor._top_index_file = top_index
executor._update_top_index("blocked")
data = json.loads(top_index.read_text(encoding="utf-8"))
mvp = next(p for p in data["phases"] if p["dir"] == "0-mvp")
assert mvp["status"] == "blocked"
assert "blocked_at" in mvp
def test_other_phases_unchanged(self, executor, top_index):
executor._top_index_file = top_index
executor._update_top_index("completed")
data = json.loads(top_index.read_text(encoding="utf-8"))
polish = next(p for p in data["phases"] if p["dir"] == "1-polish")
assert polish["status"] == "pending"
def test_nonexistent_dir_is_noop(self, executor, top_index):
executor._top_index_file = top_index
executor._phase_dir_name = "no-such-dir"
original = json.loads(top_index.read_text(encoding="utf-8"))
executor._update_top_index("completed")
after = json.loads(top_index.read_text(encoding="utf-8"))
for p_before, p_after in zip(original["phases"], after["phases"]):
assert p_before["status"] == p_after["status"]
def test_no_top_index_file(self, executor, tmp_path):
executor._top_index_file = tmp_path / "nonexistent.json"
executor._update_top_index("completed") # should not raise
# ---------------------------------------------------------------------------
# _checkout_branch (mocked)
# ---------------------------------------------------------------------------
class TestCheckoutBranch:
def _mock_git(self, executor, responses):
call_idx = {"i": 0}
def fake_git(*args):
idx = call_idx["i"]
call_idx["i"] += 1
if idx < len(responses):
return responses[idx]
return MagicMock(returncode=0, stdout="", stderr="")
executor._run_git = fake_git
def test_already_on_branch(self, executor):
self._mock_git(executor, [
MagicMock(returncode=0, stdout="feat-mvp\n", stderr=""),
])
executor._checkout_branch() # should return without checkout
def test_branch_exists_checkout(self, executor):
self._mock_git(executor, [
MagicMock(returncode=0, stdout="main\n", stderr=""),
MagicMock(returncode=0, stdout="", stderr=""),
MagicMock(returncode=0, stdout="", stderr=""),
])
executor._checkout_branch()
def test_branch_not_exists_create(self, executor):
self._mock_git(executor, [
MagicMock(returncode=0, stdout="main\n", stderr=""),
MagicMock(returncode=1, stdout="", stderr="not found"),
MagicMock(returncode=0, stdout="", stderr=""),
])
executor._checkout_branch()
def test_checkout_fails_exits(self, executor):
self._mock_git(executor, [
MagicMock(returncode=0, stdout="main\n", stderr=""),
MagicMock(returncode=1, stdout="", stderr=""),
MagicMock(returncode=1, stdout="", stderr="dirty tree"),
])
with pytest.raises(SystemExit) as exc_info:
executor._checkout_branch()
assert exc_info.value.code == 1
def test_no_git_exits(self, executor):
self._mock_git(executor, [
MagicMock(returncode=1, stdout="", stderr="not a git repo"),
])
with pytest.raises(SystemExit) as exc_info:
executor._checkout_branch()
assert exc_info.value.code == 1
# ---------------------------------------------------------------------------
# _commit_step (mocked)
# ---------------------------------------------------------------------------
class TestCommitStep:
def test_two_phase_commit(self, executor):
calls = []
def fake_git(*args):
calls.append(args)
if args[:2] == ("diff", "--cached"):
return MagicMock(returncode=1)
return MagicMock(returncode=0, stdout="", stderr="")
executor._run_git = fake_git
executor._commit_step(2, "ui")
commit_calls = [c for c in calls if c[0] == "commit"]
assert len(commit_calls) == 2
assert "feat(mvp):" in commit_calls[0][2]
assert "chore(mvp):" in commit_calls[1][2]
def test_no_code_changes_skips_feat_commit(self, executor):
call_count = {"diff": 0}
calls = []
def fake_git(*args):
calls.append(args)
if args[:2] == ("diff", "--cached"):
call_count["diff"] += 1
if call_count["diff"] == 1:
return MagicMock(returncode=0)
return MagicMock(returncode=1)
return MagicMock(returncode=0, stdout="", stderr="")
executor._run_git = fake_git
executor._commit_step(2, "ui")
commit_msgs = [c[2] for c in calls if c[0] == "commit"]
assert len(commit_msgs) == 1
assert "chore" in commit_msgs[0]
# ---------------------------------------------------------------------------
# _invoke_codex (mocked)
# ---------------------------------------------------------------------------
class TestInvokeCodex:
def test_invokes_codex_with_correct_args(self, executor):
mock_result = MagicMock(returncode=0, stdout='{"result": "ok"}', stderr="")
step = {"step": 2, "name": "ui"}
preamble = "PREAMBLE\n"
with patch("subprocess.run", return_value=mock_result) as mock_run:
output = executor._invoke_codex(step, preamble)
cmd = mock_run.call_args[0][0]
assert cmd[:2] == ["codex", "exec"]
assert "--skip-git-repo-check" in cmd
assert "--full-auto" in cmd
assert "--json" in cmd
assert cmd[-1] == "-"
assert "PREAMBLE" in mock_run.call_args[1]["input"]
assert "UI를 구현하세요" in mock_run.call_args[1]["input"]
def test_saves_output_json(self, executor):
mock_result = MagicMock(returncode=0, stdout='{"ok": true}', stderr="")
step = {"step": 2, "name": "ui"}
with patch("subprocess.run", return_value=mock_result):
executor._invoke_codex(step, "preamble")
output_file = executor._phase_dir / "step2-output.json"
assert output_file.exists()
data = json.loads(output_file.read_text(encoding="utf-8"))
assert data["step"] == 2
assert data["name"] == "ui"
assert data["exitCode"] == 0
def test_nonexistent_step_file_exits(self, executor):
step = {"step": 99, "name": "nonexistent"}
with pytest.raises(SystemExit) as exc_info:
executor._invoke_codex(step, "preamble")
assert exc_info.value.code == 1
def test_timeout_is_1800(self, executor):
mock_result = MagicMock(returncode=0, stdout="{}", stderr="")
step = {"step": 2, "name": "ui"}
with patch("subprocess.run", return_value=mock_result) as mock_run:
executor._invoke_codex(step, "preamble")
assert mock_run.call_args[1]["timeout"] == 1800
# ---------------------------------------------------------------------------
# progress_indicator (= 이전 Spinner)
# ---------------------------------------------------------------------------
class TestProgressIndicator:
def test_context_manager(self):
import time
with ex.progress_indicator("test") as pi:
time.sleep(0.15)
assert pi.elapsed >= 0.1
def test_elapsed_increases(self):
import time
with ex.progress_indicator("test") as pi:
time.sleep(0.2)
assert pi.elapsed > 0
# ---------------------------------------------------------------------------
# main() CLI 파싱 (mocked)
# ---------------------------------------------------------------------------
class TestMainCli:
def test_no_args_exits(self):
with patch("sys.argv", ["execute.py"]):
with pytest.raises(SystemExit) as exc_info:
ex.main()
assert exc_info.value.code == 2 # argparse exits with 2
def test_invalid_phase_dir_exits(self):
with patch("sys.argv", ["execute.py", "nonexistent"]):
with patch.object(ex, "ROOT", Path("/tmp/fake_nonexistent")):
with pytest.raises(SystemExit) as exc_info:
ex.main()
assert exc_info.value.code == 1
def test_missing_index_exits(self, tmp_project):
(tmp_project / "phases" / "empty").mkdir()
with patch("sys.argv", ["execute.py", "empty"]):
with patch.object(ex, "ROOT", tmp_project):
with pytest.raises(SystemExit) as exc_info:
ex.main()
assert exc_info.value.code == 1
# ---------------------------------------------------------------------------
# _check_blockers (= 이전 main() error/blocked 체크)
# ---------------------------------------------------------------------------
class TestCheckBlockers:
def _make_executor_with_steps(self, tmp_project, steps):
d = tmp_project / "phases" / "test-phase"
d.mkdir(exist_ok=True)
index = {"project": "T", "phase": "test", "steps": steps}
(d / "index.json").write_text(json.dumps(index), encoding="utf-8")
with patch.object(ex, "ROOT", tmp_project):
inst = ex.StepExecutor.__new__(ex.StepExecutor)
inst._root = str(tmp_project)
inst._phases_dir = tmp_project / "phases"
inst._phase_dir = d
inst._phase_dir_name = "test-phase"
inst._index_file = d / "index.json"
inst._top_index_file = tmp_project / "phases" / "index.json"
inst._phase_name = "test"
inst._total = len(steps)
return inst
def test_error_step_exits_1(self, tmp_project):
steps = [
{"step": 0, "name": "ok", "status": "completed"},
{"step": 1, "name": "bad", "status": "error", "error_message": "fail"},
]
inst = self._make_executor_with_steps(tmp_project, steps)
with pytest.raises(SystemExit) as exc_info:
inst._check_blockers()
assert exc_info.value.code == 1
def test_blocked_step_exits_2(self, tmp_project):
steps = [
{"step": 0, "name": "ok", "status": "completed"},
{"step": 1, "name": "stuck", "status": "blocked", "blocked_reason": "API key"},
]
inst = self._make_executor_with_steps(tmp_project, steps)
with pytest.raises(SystemExit) as exc_info:
inst._check_blockers()
assert exc_info.value.code == 2
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""
Basic validation for the Markdown document harness template.
This check is intentionally lightweight: it verifies that the template files
exist and keep the sections that later Harness steps depend on.
"""
import json
from pathlib import Path
import sys
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover - Python < 3.11 compatibility
tomllib = None
ROOT = Path(__file__).resolve().parent.parent
REQUIRED_FILES = [
"README.md",
"AGENTS.md",
"docs/PRD.md",
"docs/ResearchNote.md",
"docs/DraftFeedback.md",
"docs/FinalFeedback.md",
"docs/ARCHITECTURE.md",
"docs/ADR.md",
"docs/UI_GUIDE.md",
".agents/skills/document-harness/SKILL.md",
".agents/skills/document-harness/references/phase-templates.md",
".agents/skills/document-review/SKILL.md",
".codex/config.toml",
".codex/hooks.json",
".codex/hooks/pre_tool_guard.py",
".codex/hooks/stop_validate.py",
".codex/agents/doc_researcher.toml",
".codex/agents/doc_drafter.toml",
".codex/agents/doc_reviewer.toml",
".codex/agents/evidence_checker.toml",
]
REQUIRED_DIRS = [
"docs",
"scripts",
".agents",
".agents/skills",
".agents/skills/document-harness",
".agents/skills/document-review",
".codex",
".codex/hooks",
".codex/agents",
]
REQUIRED_SECTIONS = {
"README.md": [
"## 핵심 아이디어",
"## Codex 구성",
"## 빠른 시작",
"## 자동 실행 방식",
"## 피드백 게이트",
"## 검증",
],
"docs/PRD.md": [
"## 문서 목적",
"## 대상 독자",
"## 최종 산출물",
"## 문서 개요",
"## 중요 키워드",
"## 핵심 질문",
"## 범위",
"## 톤과 스타일",
"## 조사 요구사항",
"## 사용자 피드백 방식",
],
"docs/ResearchNote.md": [
"## 조사 범위",
"## 조사 일시",
"## 검색어",
"## 핵심 결론",
"## 출처 목록",
"## 쟁점과 상반된 주장",
"## 확인 필요",
],
"AGENTS.md": [
"## 목적",
"## Codex 구성",
"## 기본 산출물",
"## 문서 작성 규칙",
"## Codex 작업 규칙",
"## 권장 워크플로우",
"## 명령어",
],
".agents/skills/document-harness/SKILL.md": [
"# Document Harness Skill",
"## Operating Rules",
"## Staged Workflow",
"## Validation",
],
".agents/skills/document-review/SKILL.md": [
"# Document Review Skill",
"## Read First",
"## Review Checklist",
"## Output Format",
],
}
REQUIRED_JSON_FILES = [
".codex/hooks.json",
]
REQUIRED_TOML_FILES = [
".codex/config.toml",
".codex/agents/doc_researcher.toml",
".codex/agents/doc_drafter.toml",
".codex/agents/doc_reviewer.toml",
".codex/agents/evidence_checker.toml",
]
def first_nonempty_line(path: Path) -> str:
for line in path.read_text(encoding="utf-8").splitlines():
if line.strip():
return line.strip()
return ""
def markdown_file_has_valid_start(path: Path) -> bool:
first = first_nonempty_line(path)
if first.startswith("# "):
return True
if first == "---" and path.name == "SKILL.md":
return True
return False
def main() -> int:
errors: list[str] = []
for rel in REQUIRED_DIRS:
path = ROOT / rel
if not path.is_dir():
errors.append(f"missing directory: {rel}")
for rel in REQUIRED_FILES:
path = ROOT / rel
if not path.is_file():
errors.append(f"missing file: {rel}")
continue
if path.suffix == ".md":
if not markdown_file_has_valid_start(path):
errors.append(f"markdown file must start with a level-1 heading or Skill frontmatter: {rel}")
for rel, sections in REQUIRED_SECTIONS.items():
path = ROOT / rel
if not path.is_file():
continue
text = path.read_text(encoding="utf-8")
for section in sections:
if section not in text:
errors.append(f"missing section in {rel}: {section}")
for rel in REQUIRED_JSON_FILES:
path = ROOT / rel
if not path.is_file():
continue
try:
json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
errors.append(f"invalid JSON in {rel}: {exc}")
if tomllib is not None:
for rel in REQUIRED_TOML_FILES:
path = ROOT / rel
if not path.is_file():
continue
try:
tomllib.loads(path.read_text(encoding="utf-8"))
except tomllib.TOMLDecodeError as exc:
errors.append(f"invalid TOML in {rel}: {exc}")
if errors:
print("Document harness validation failed:")
for error in errors:
print(f"- {error}")
return 1
print("Document harness validation passed.")
return 0
if __name__ == "__main__":
sys.exit(main())