modify documents
This commit is contained in:
@@ -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.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 구조를 기준으로 합니다.
|
||||
@@ -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을 기준으로 한 문서 작성 자동화가 명확해진다.
|
||||
@@ -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`는 독립 실행 가능한 작업 지시서다.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Draft Feedback
|
||||
|
||||
초안 검토 후 사용자가 피드백을 남기는 파일이다. AI Agent는 이 파일을 읽고 `final/` 문서에 반영한다.
|
||||
|
||||
## 검토 대상
|
||||
- `drafts/{파일명}`
|
||||
|
||||
## 전체 판단
|
||||
- {예: 방향 승인 / 구조 수정 필요 / 추가 조사 필요 / 톤 변경 필요}
|
||||
|
||||
## 수정 요청
|
||||
| 위치 | 요청 | 이유 |
|
||||
|------|------|------|
|
||||
| {섹션 또는 파일명} | {수정 요청} | {왜 필요한지} |
|
||||
|
||||
## 추가로 포함할 내용
|
||||
- {추가 내용}
|
||||
|
||||
## 제외하거나 줄일 내용
|
||||
- {삭제/축소할 내용}
|
||||
|
||||
## 승인 여부
|
||||
{예: 초안 방향 승인 / 아직 승인하지 않음}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Final Feedback
|
||||
|
||||
최종 문서 검토 후 사용자가 승인 또는 추가 수정 요청을 남기는 파일이다.
|
||||
|
||||
## 검토 대상
|
||||
- `final/{파일명}`
|
||||
|
||||
## 승인 여부
|
||||
{예: 승인 / 수정 후 승인 / 승인하지 않음}
|
||||
|
||||
## 최종 수정 요청
|
||||
| 위치 | 요청 | 우선순위 |
|
||||
|------|------|----------|
|
||||
| {섹션 또는 파일명} | {수정 요청} | {높음/중간/낮음} |
|
||||
|
||||
## 비고
|
||||
- {추가 의견}
|
||||
@@ -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`
|
||||
- 승인 기준: {예: 사용자가 명시적으로 "승인"이라고 남기면 완료}
|
||||
@@ -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 | 판단/처리 |
|
||||
|------|--------|--------|-----------|
|
||||
| {쟁점} | {내용과 출처} | {내용과 출처} | {문서에서 어떻게 다룰지} |
|
||||
|
||||
## 확인 필요
|
||||
- {추가 확인이 필요한 사실}
|
||||
- {사용자에게 물어봐야 할 내용}
|
||||
|
||||
## 문서 반영 메모
|
||||
- {어떤 결론을 어떤 문서/섹션에 반영할지}
|
||||
@@ -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 지침과 충돌하지 않는가?
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user