modify template
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "local-harness-engineering",
|
||||
"interface": {
|
||||
"displayName": "Local Report Harness"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "harness-engineering",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/harness-engineering"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Productivity"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: harness-review
|
||||
description: Review a Report Harness repository against its persistent rules and report docs. Use when Codex is asked to review local changes, generated phase files, research logs, report drafts, feedback handling, source quality, or output against `AGENTS.md`, `docs/ARCHITECTURE.md`, `docs/ADR.md`, `docs/UI_GUIDE.md`, and Harness step acceptance criteria.
|
||||
---
|
||||
|
||||
# Report Harness Review
|
||||
|
||||
Use this skill when the user wants a repository-grounded report review instead of generic commentary.
|
||||
|
||||
## Review input set
|
||||
|
||||
Read these first:
|
||||
|
||||
- `/AGENTS.md`
|
||||
- `/docs/PRD.md`
|
||||
- `/docs/ARCHITECTURE.md`
|
||||
- `/docs/ADR.md`
|
||||
- `/docs/UI_GUIDE.md`
|
||||
- `/docs/RESEARCH_LOG.md`
|
||||
- `/docs/REPORT_DRAFT.md`
|
||||
- `/docs/FEEDBACK.md`
|
||||
- the changed files or generated `phases/` files under review
|
||||
|
||||
If the user explicitly asks for delegated review, prefer the repo custom agent `harness_reviewer` or built-in read-only explorers.
|
||||
|
||||
## Checklist
|
||||
|
||||
Evaluate the patch against these questions:
|
||||
|
||||
1. Does it follow the report workflow described in `docs/ARCHITECTURE.md`?
|
||||
2. Does it stay within the research and writing decisions documented in `docs/ADR.md`?
|
||||
3. Does it satisfy the topic, purpose, keywords, direction, audience, and output format in `docs/PRD.md`?
|
||||
4. Are new factual claims backed by sources in `docs/RESEARCH_LOG.md` or clearly marked as `검증 필요`?
|
||||
5. Are source quality, publication/access dates, conflicts, and uncertainty handled explicitly?
|
||||
6. Does it violate any CRITICAL rule in `AGENTS.md`?
|
||||
7. Do generated `phases/` files remain self-contained, executable, and internally consistent?
|
||||
8. If the user expects verification, does `python scripts/validate_workspace.py` succeed or is the failure explained?
|
||||
|
||||
## Output rules
|
||||
|
||||
- Lead with findings, ordered by severity.
|
||||
- Include file references for each finding.
|
||||
- Explain the concrete report-quality risk, not just the rule name.
|
||||
- If there are no findings, say so explicitly and mention residual risks or missing evidence.
|
||||
- Keep summaries brief after the findings.
|
||||
|
||||
## Preferred review table
|
||||
|
||||
When the user asks for a checklist-style review, use this table:
|
||||
|
||||
| Item | Result | Notes |
|
||||
|------|------|------|
|
||||
| Brief alignment | PASS/FAIL | {details} |
|
||||
| Research coverage | PASS/FAIL | {details} |
|
||||
| Source quality | PASS/FAIL | {details} |
|
||||
| Claim support | PASS/FAIL | {details} |
|
||||
| Feedback handling | PASS/FAIL | {details} |
|
||||
| CRITICAL rules | PASS/FAIL | {details} |
|
||||
| Validation | PASS/FAIL | {details} |
|
||||
|
||||
## What not to do
|
||||
|
||||
- Do not approve a draft just because it is well written.
|
||||
- Do not ignore unsupported claims, stale sources, or missing counterarguments.
|
||||
- Do not assume a passing hook means the report is acceptable; review the actual documents and phase files.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Report Harness Review"
|
||||
short_description: "Review report artifacts against Report Harness rules"
|
||||
default_prompt: "Use Report Harness review to check source quality, claim support, feedback handling, and rules."
|
||||
@@ -0,0 +1,173 @@
|
||||
---
|
||||
name: harness-workflow
|
||||
description: Plan and run the Report Harness workflow for this repository. Use when Codex needs to read `AGENTS.md` and `docs/*.md`, discuss report scope, draft research/drafting/revision phase plans, or create/update `phases/index.json`, `phases/{phase}/index.json`, and `phases/{phase}/stepN.md` files for staged execution.
|
||||
---
|
||||
|
||||
# Report Harness Workflow
|
||||
|
||||
Use this skill when the user is working in the report-generation Harness template and wants structured planning or phase-file generation.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Explore first
|
||||
|
||||
Read these files before proposing steps:
|
||||
|
||||
- `/AGENTS.md`
|
||||
- `/docs/PRD.md`
|
||||
- `/docs/ARCHITECTURE.md`
|
||||
- `/docs/ADR.md`
|
||||
- `/docs/UI_GUIDE.md`
|
||||
- `/docs/RESEARCH_LOG.md`
|
||||
- `/docs/REPORT_DRAFT.md`
|
||||
- `/docs/FEEDBACK.md`
|
||||
|
||||
If the user explicitly asks for parallel exploration, use built-in Codex subagents such as `explorer`, or the repo-scoped custom agent `phase_planner`.
|
||||
|
||||
### 2. Confirm report inputs before locking the plan
|
||||
|
||||
Check that `docs/PRD.md` has enough information for:
|
||||
|
||||
1. Report topic.
|
||||
2. Purpose and target reader.
|
||||
3. Required keywords.
|
||||
4. Direction or stance.
|
||||
5. Output format and success criteria.
|
||||
|
||||
If any of these are missing and cannot be reasonably inferred, create a short `blocked` step or ask the user for the missing input instead of fabricating intent.
|
||||
|
||||
### 3. Design steps with strict boundaries
|
||||
|
||||
When drafting a report phase plan:
|
||||
|
||||
1. Keep each step focused on one research question, source-review pass, outline pass, draft pass, or feedback pass.
|
||||
2. Make each `stepN.md` self-contained so it can run in an isolated Codex session.
|
||||
3. List prerequisite files explicitly. Never rely on "as discussed above".
|
||||
4. Specify report artifacts and invariants, not line-by-line prose.
|
||||
5. Use executable acceptance commands, not vague success criteria.
|
||||
6. Write concrete warnings in "do not do X because Y" form.
|
||||
7. Use kebab-case step names.
|
||||
8. Require web search for facts, current information, market data, policy/law, statistics, or recommendations.
|
||||
9. Require source notes in `docs/RESEARCH_LOG.md` before adding claims to `docs/REPORT_DRAFT.md`.
|
||||
|
||||
Recommended step sequence:
|
||||
|
||||
1. `brief-audit`: validate the report brief and derive research questions.
|
||||
2. `source-discovery`: perform broad web search and record candidate sources.
|
||||
3. `deep-research-{topic}`: research one specific keyword, stakeholder, region, or issue.
|
||||
4. `source-review`: check source quality, conflicts, freshness, and missing evidence.
|
||||
5. `outline`: design the report structure from the evidence.
|
||||
6. `draft-report`: write or revise `docs/REPORT_DRAFT.md`.
|
||||
7. `feedback-questions`: add targeted questions for the user and update `docs/FEEDBACK.md`.
|
||||
|
||||
## Files to generate
|
||||
|
||||
### `phases/index.json`
|
||||
|
||||
Top-level phase registry. Append to `phases[]` when the file already exists.
|
||||
|
||||
```json
|
||||
{
|
||||
"phases": [
|
||||
{
|
||||
"dir": "0-research",
|
||||
"status": "pending"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `dir`: phase directory name.
|
||||
- `status`: `pending`, `completed`, `error`, or `blocked`.
|
||||
- Timestamp fields are written by `scripts/execute.py`; do not seed them during planning.
|
||||
|
||||
### `phases/{phase}/index.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"project": "<report-project-name>",
|
||||
"phase": "<phase-name>",
|
||||
"steps": [
|
||||
{ "step": 0, "name": "brief-audit", "status": "pending" },
|
||||
{ "step": 1, "name": "source-discovery", "status": "pending" },
|
||||
{ "step": 2, "name": "draft-outline", "status": "pending" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `project`: from `AGENTS.md` or `docs/PRD.md`.
|
||||
- `phase`: directory name.
|
||||
- `steps[].step`: zero-based integer.
|
||||
- `steps[].name`: kebab-case slug.
|
||||
- `steps[].status`: initialize to `pending`.
|
||||
|
||||
### `phases/{phase}/stepN.md`
|
||||
|
||||
Each step file should contain:
|
||||
|
||||
1. A title.
|
||||
2. A "read these files first" section.
|
||||
3. A concrete task section.
|
||||
4. Executable acceptance criteria.
|
||||
5. Verification instructions.
|
||||
6. Explicit prohibitions.
|
||||
|
||||
Recommended structure:
|
||||
|
||||
````markdown
|
||||
# Step {N}: {name}
|
||||
|
||||
## Read First
|
||||
- /AGENTS.md
|
||||
- /docs/PRD.md
|
||||
- /docs/ARCHITECTURE.md
|
||||
- /docs/ADR.md
|
||||
- /docs/UI_GUIDE.md
|
||||
- /docs/RESEARCH_LOG.md
|
||||
- /docs/REPORT_DRAFT.md
|
||||
- /docs/FEEDBACK.md
|
||||
- {files from previous steps}
|
||||
|
||||
## Task
|
||||
{specific report research, source review, drafting, or feedback instructions}
|
||||
|
||||
## Acceptance Criteria
|
||||
```bash
|
||||
python scripts/validate_workspace.py
|
||||
```
|
||||
|
||||
## Verification
|
||||
1. Run the acceptance commands.
|
||||
2. Check `AGENTS.md` and `docs/*.md` for report-rule drift.
|
||||
3. Confirm every new factual claim has a source or is listed under `검증 필요`.
|
||||
4. Update the matching step in `phases/{phase}/index.json`:
|
||||
- completed + summary
|
||||
- error + error_message
|
||||
- blocked + blocked_reason
|
||||
|
||||
## Do Not
|
||||
- {concrete prohibition}
|
||||
````
|
||||
|
||||
## Execution
|
||||
|
||||
Run the generated phase with:
|
||||
|
||||
```bash
|
||||
python scripts/execute.py <phase-name>
|
||||
python scripts/execute.py <phase-name> --push
|
||||
```
|
||||
|
||||
`scripts/execute.py` handles:
|
||||
|
||||
- `report-{phase}` branch checkout/creation when the directory is a git repository
|
||||
- guardrail injection from `AGENTS.md` and `docs/*.md`
|
||||
- accumulation of completed-step summaries into later prompts
|
||||
- up to 3 retries with prior error feedback
|
||||
- two-phase commit of report artifact changes and metadata updates
|
||||
- timestamps such as `created_at`, `started_at`, `completed_at`, `failed_at`, and `blocked_at`
|
||||
|
||||
## Recovery rules
|
||||
|
||||
- If a step is `error`, reset its status to `pending`, remove `error_message`, then rerun.
|
||||
- If a step is `blocked`, resolve the blocker, reset to `pending`, remove `blocked_reason`, then rerun.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Report Harness Workflow"
|
||||
short_description: "Guide Codex through report phase planning"
|
||||
default_prompt: "Use the Report Harness workflow to plan research, drafting, and revision steps."
|
||||
@@ -0,0 +1,11 @@
|
||||
name = "harness_reviewer"
|
||||
description = "Read-only reviewer for Report Harness projects, focused on brief alignment, source quality, unsupported claims, feedback handling, and critical rule violations."
|
||||
model = "gpt-5.4"
|
||||
model_reasoning_effort = "high"
|
||||
sandbox_mode = "read-only"
|
||||
developer_instructions = """
|
||||
Review changes like a report owner.
|
||||
Prioritize brief alignment, factual support, source quality, uncertainty handling, feedback traceability, and CRITICAL rules over style.
|
||||
Always compare the patch against AGENTS.md, docs/PRD.md, docs/ARCHITECTURE.md, docs/ADR.md, docs/UI_GUIDE.md, and the requested acceptance criteria.
|
||||
Lead with concrete findings and file references. If no material issues are found, say so explicitly and mention residual risks.
|
||||
"""
|
||||
@@ -0,0 +1,12 @@
|
||||
name = "phase_planner"
|
||||
description = "Read-heavy Report Harness planner that decomposes report briefs into minimal, self-contained research, drafting, and revision phase files."
|
||||
model = "gpt-5.4"
|
||||
model_reasoning_effort = "high"
|
||||
sandbox_mode = "read-only"
|
||||
developer_instructions = """
|
||||
Plan before implementing.
|
||||
Read AGENTS.md and the docs directory, identify the smallest coherent report phase boundaries, and draft self-contained steps.
|
||||
Keep each step scoped to one research question, source review task, outline task, draft task, or feedback task when possible.
|
||||
Do not make file changes unless the parent agent explicitly asks you to write files.
|
||||
Return concrete file paths, acceptance commands, source requirements, and blocking assumptions.
|
||||
"""
|
||||
@@ -0,0 +1,9 @@
|
||||
# Project-scoped Codex defaults for the Report Harness template.
|
||||
# As of 2026-04-15, hooks are experimental and disabled on native Windows.
|
||||
|
||||
[features]
|
||||
codex_hooks = true
|
||||
|
||||
[agents]
|
||||
max_threads = 6
|
||||
max_depth = 1
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/pre_tool_use_policy.py\"",
|
||||
"statusMessage": "Checking risky shell command"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/stop_continue.py\"",
|
||||
"statusMessage": "Running Harness validation",
|
||||
"timeout": 300
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Block obviously destructive shell commands before Codex runs them."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
BLOCK_PATTERNS = (
|
||||
r"\brm\s+-rf\b",
|
||||
r"\bgit\s+push\s+--force(?:-with-lease)?\b",
|
||||
r"\bgit\s+reset\s+--hard\b",
|
||||
r"\bDROP\s+TABLE\b",
|
||||
r"\btruncate\s+table\b",
|
||||
r"\bRemove-Item\b.*\b-Recurse\b",
|
||||
r"\bdel\b\s+/s\b",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
except json.JSONDecodeError:
|
||||
return 0
|
||||
|
||||
command = payload.get("tool_input", {}).get("command", "")
|
||||
for pattern in BLOCK_PATTERNS:
|
||||
if re.search(pattern, command, re.IGNORECASE):
|
||||
json.dump(
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": "Harness guardrail blocked a risky shell command.",
|
||||
}
|
||||
},
|
||||
sys.stdout,
|
||||
)
|
||||
return 0
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run Report Harness validation when a Codex turn stops and request one more pass if it fails."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
except json.JSONDecodeError:
|
||||
return 0
|
||||
|
||||
if payload.get("stop_hook_active"):
|
||||
return 0
|
||||
|
||||
root = Path(payload.get("cwd") or ".").resolve()
|
||||
validator = root / "scripts" / "validate_workspace.py"
|
||||
if not validator.exists():
|
||||
return 0
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(validator)],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=240,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return 0
|
||||
|
||||
summary = (result.stdout or result.stderr or "workspace validation failed").strip()
|
||||
if len(summary) > 1200:
|
||||
summary = summary[:1200].rstrip() + "..."
|
||||
|
||||
json.dump(
|
||||
{
|
||||
"decision": "block",
|
||||
"reason": (
|
||||
"Validation failed. Review the output, fix the repo, then continue.\n\n"
|
||||
f"{summary}"
|
||||
),
|
||||
},
|
||||
sys.stdout,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,60 @@
|
||||
# Project: {보고서 프로젝트명}
|
||||
|
||||
## Repository Role
|
||||
- This repository is a Codex-first Report Generation Harness template.
|
||||
- Persistent repository instructions live in this `AGENTS.md`.
|
||||
- Reusable repo-scoped workflows live in `.agents/skills/`.
|
||||
- Project-scoped custom agents live in `.codex/agents/`.
|
||||
- Experimental hooks live in `.codex/hooks.json`.
|
||||
- The repository is optimized for evidence-backed report creation, not application development.
|
||||
|
||||
## 보고서 입력값
|
||||
- `docs/PRD.md`에 아래 내용을 먼저 입력한다.
|
||||
- 보고서 주제: {무엇에 대한 보고서인지 한 문장으로 작성}
|
||||
- 용도: {의사결정, 내부 공유, 시장 조사, 전략 수립, 기술 검토, 정책 검토 등}
|
||||
- 핵심 키워드: {보고서 내용에 반드시 포함할 키워드, 산업, 기업, 지역, 기간, 이해관계자}
|
||||
- 방향성: {중립 분석, 찬반 비교, 실행 전략, 리스크 중심, 트렌드 전망, 근거 기반 추천 등}
|
||||
- 독자: {최종 보고서를 읽는 사람과 기대 수준}
|
||||
- 형식: {요약 보고서, 심층 보고서, 임원 브리프, 조사 메모, 백서 초안 등}
|
||||
|
||||
## 산출물 규칙
|
||||
- CRITICAL: 사실 주장, 수치, 최신 동향, 법/정책/시장 정보는 반드시 웹 검색으로 확인하고 출처를 남길 것.
|
||||
- CRITICAL: 출처가 없는 핵심 주장은 보고서 본문에 단정적으로 쓰지 말고 `docs/RESEARCH_LOG.md`의 `검증 필요` 항목에 남길 것.
|
||||
- CRITICAL: 검색 출처는 제목, 발행기관, URL, 발행일 또는 접근일, 핵심 내용, 신뢰도 메모를 함께 기록할 것.
|
||||
- CRITICAL: 사용자가 제공한 방향성과 충돌하는 보고서 구조 변경은 먼저 피드백 요청 항목으로 남길 것.
|
||||
- 보고서 초안은 `docs/REPORT_DRAFT.md`에 작성한다.
|
||||
- 조사 누적 기록은 `docs/RESEARCH_LOG.md`에 작성한다.
|
||||
- 사용자 피드백과 반영 이력은 `docs/FEEDBACK.md`에 작성한다.
|
||||
- 상대 날짜 표현(오늘, 최근, 작년 등)은 가능한 한 절대 날짜로 풀어 쓴다.
|
||||
|
||||
## Report Harness Workflow
|
||||
- 먼저 `docs/PRD.md`, `docs/ARCHITECTURE.md`, `docs/ADR.md`, `docs/UI_GUIDE.md`, `docs/RESEARCH_LOG.md`, `docs/REPORT_DRAFT.md`, `docs/FEEDBACK.md`를 읽고 보고서 의도를 파악할 것.
|
||||
- 단계별 실행 계획이 필요하면 repo skill `harness-workflow`를 사용해 `phases/` 아래 파일을 설계할 것.
|
||||
- 변경사항 리뷰가 필요하면 repo skill `harness-review` 또는 Codex의 `/review`를 사용할 것.
|
||||
- `phases/{phase}/index.json`은 phase 진행 상태의 단일 진실 공급원으로 취급할 것.
|
||||
- 각 `stepN.md`는 독립된 Codex 세션에서도 실행 가능하도록 자기완결적으로 작성할 것.
|
||||
|
||||
## 보고서 생성 프로세스
|
||||
- 사용자가 `docs/PRD.md`의 입력값을 채운다.
|
||||
- Codex는 필요한 AI agent 역할을 설계한다. 예: research lead, domain researcher, source reviewer, outline writer, report editor.
|
||||
- 웹 검색을 통해 주제별 근거를 축적하고, 모든 핵심 근거를 `docs/RESEARCH_LOG.md`에 남긴다.
|
||||
- 축적된 근거를 바탕으로 `docs/REPORT_DRAFT.md`에 초안을 작성한다.
|
||||
- 초안 말미에 사용자에게 확인할 피드백 질문을 남기고, 답변은 `docs/FEEDBACK.md`에 축적한다.
|
||||
- 피드백 반영 후 필요한 추가 검색, 구조 변경, 문체 조정, 결론 보강을 반복한다.
|
||||
|
||||
## 품질 기준
|
||||
- 결론은 근거보다 앞서가면 안 된다.
|
||||
- 반대 근거, 불확실성, 데이터 한계, 이해관계자별 관점을 별도 섹션으로 다룬다.
|
||||
- 출처 품질은 1차 자료, 공식 문서, 학술/정부/공공기관, 신뢰도 높은 언론/리서치 순으로 우선한다.
|
||||
- 웹 검색 결과가 서로 충돌하면 충돌 내용을 숨기지 말고 비교 표로 정리한다.
|
||||
- 보고서에는 독자가 다음 행동을 결정할 수 있는 요약, 근거, 리스크, 권고가 포함되어야 한다.
|
||||
|
||||
## 검증
|
||||
- 기본 검증 스크립트는 `python scripts/validate_workspace.py`.
|
||||
- `HARNESS_VALIDATION_COMMANDS` 환경 변수에 줄바꿈 기준으로 검증 커맨드를 지정하면 해당 커맨드를 실행한다.
|
||||
- Node 프로젝트가 추가된 경우 `package.json`의 `lint`, `build`, `test` 스크립트를 자동 탐지해 순서대로 실행한다.
|
||||
|
||||
## 명령어
|
||||
- `python scripts/execute.py <phase-dir>`: Codex 기반 report phase 순차 실행.
|
||||
- `python scripts/execute.py <phase-dir> --push`: phase 완료 후 브랜치 push.
|
||||
- `python scripts/validate_workspace.py`: 저장소 검증.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Report Generation Harness Template
|
||||
|
||||
Codex-first template for creating evidence-backed reports through staged research, drafting, user feedback, and revision.
|
||||
|
||||
## How To Use
|
||||
1. Fill in `docs/PRD.md` with the report topic, purpose, keywords, direction, audience, and output format.
|
||||
2. Run the default phase with `python scripts/execute.py 0-report-generation`, or ask Codex to plan a custom report phase with the repo `harness-workflow` skill or `/harness` command.
|
||||
3. Review `docs/RESEARCH_LOG.md` and `docs/REPORT_DRAFT.md`.
|
||||
4. Add user feedback to `docs/FEEDBACK.md`, then run the next revision phase.
|
||||
|
||||
## Core Files
|
||||
- `AGENTS.md`: persistent report-generation rules.
|
||||
- `docs/PRD.md`: report brief and required user input.
|
||||
- `docs/ARCHITECTURE.md`: report workflow and artifact architecture.
|
||||
- `docs/ADR.md`: research and writing decisions.
|
||||
- `docs/UI_GUIDE.md`: report style guide.
|
||||
- `docs/RESEARCH_LOG.md`: accumulated web research and source notes.
|
||||
- `docs/REPORT_DRAFT.md`: current report draft.
|
||||
- `docs/FEEDBACK.md`: feedback and revision log.
|
||||
|
||||
## Commands
|
||||
```bash
|
||||
python scripts/validate_workspace.py
|
||||
python scripts/execute.py 0-report-generation
|
||||
python scripts/execute.py 0-report-generation --push
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
# Report Decision Records
|
||||
|
||||
## 철학
|
||||
보고서는 빠르게 그럴듯한 문장을 만드는 것이 아니라, 사용자가 판단할 수 있는 근거를 축적하고 그 근거로부터 결론을 구성하는 작업이다. 속도보다 출처, 검증 가능성, 피드백 반복성을 우선한다.
|
||||
|
||||
---
|
||||
|
||||
### ADR-001: 문서 파일을 단일 진실 공급원으로 사용
|
||||
**결정**: 보고서 입력, 리서치 로그, 초안, 피드백을 모두 `docs/` 아래 Markdown 파일로 관리한다.
|
||||
**이유**: 독립된 Codex 세션과 phase 실행 사이에서도 맥락을 잃지 않고 이어갈 수 있다.
|
||||
**트레이드오프**: 문서가 길어질수록 정리 비용이 늘어나므로 중복 출처와 폐기된 주장 정리가 필요하다.
|
||||
|
||||
### ADR-002: 웹 검색과 출처 기록을 기본값으로 둔다
|
||||
**결정**: 최신성 또는 사실 정확성이 필요한 보고서 내용은 웹 검색 후 `docs/RESEARCH_LOG.md`에 출처를 기록한다.
|
||||
**이유**: 보고서 품질은 모델 기억보다 검증 가능한 근거에 의해 결정된다.
|
||||
**트레이드오프**: 초안 작성 속도는 느려지지만, 사용자가 검토하고 수정할 수 있는 근거가 남는다.
|
||||
|
||||
### ADR-003: 초안 작성 전에 리서치 로그를 먼저 축적한다
|
||||
**결정**: 바로 본문을 쓰지 않고 리서치 질문, 검색 결과, 상충 근거, 한계를 먼저 정리한다.
|
||||
**이유**: 결론이 근거보다 앞서가는 것을 막고, 보고서 방향성을 더 쉽게 조정할 수 있다.
|
||||
**트레이드오프**: 짧은 보고서에도 최소한의 리서치 단계가 필요하다.
|
||||
|
||||
### ADR-004: 피드백 반복을 명시적 산출물로 관리한다
|
||||
**결정**: 사용자 피드백은 `docs/FEEDBACK.md`에 원문과 반영 결과를 함께 기록한다.
|
||||
**이유**: 반복 작성 과정에서 어떤 요구가 반영되었고 무엇이 보류되었는지 추적할 수 있다.
|
||||
**트레이드오프**: 피드백 관리 문서를 별도로 갱신해야 한다.
|
||||
@@ -0,0 +1,71 @@
|
||||
# 보고서 생성 아키텍처
|
||||
|
||||
## 디렉토리 구조
|
||||
```text
|
||||
docs/
|
||||
├── PRD.md # 사용자가 입력하는 보고서 브리프
|
||||
├── ARCHITECTURE.md # 보고서 생성 흐름과 산출물 구조
|
||||
├── ADR.md # 리서치/작성 의사결정
|
||||
├── UI_GUIDE.md # 보고서 문체와 형식 가이드
|
||||
├── RESEARCH_LOG.md # 웹 검색 결과, 출처, 검증 메모
|
||||
├── REPORT_DRAFT.md # 현재 보고서 초안
|
||||
└── FEEDBACK.md # 사용자 피드백과 반영 이력
|
||||
|
||||
phases/
|
||||
├── index.json
|
||||
└── {phase}/
|
||||
├── index.json
|
||||
├── step0.md
|
||||
├── step1.md
|
||||
└── stepN.md
|
||||
```
|
||||
|
||||
## 에이전트 역할 패턴
|
||||
- Research Lead: 브리프를 읽고 리서치 질문, 검색 범위, 단계 계획을 정의한다.
|
||||
- Domain Researcher: 특정 키워드, 산업, 지역, 기간을 맡아 웹 검색하고 근거를 축적한다.
|
||||
- Source Reviewer: 출처 신뢰도, 최신성, 상충 자료, 과장된 해석을 점검한다.
|
||||
- Outline Writer: 리서치 로그를 바탕으로 보고서 구조와 핵심 메시지를 설계한다.
|
||||
- Report Editor: 초안을 작성하고 문체, 흐름, 근거 연결, 피드백 반영을 정리한다.
|
||||
|
||||
하나의 Codex 세션이 여러 역할을 수행할 수 있다. 사용자가 명시적으로 subagent 사용을 요청한 경우에만 병렬 subagent를 구성한다.
|
||||
|
||||
## 데이터 흐름
|
||||
```text
|
||||
사용자 입력
|
||||
-> docs/PRD.md
|
||||
-> phase plan 생성
|
||||
-> 웹 검색 및 출처 검증
|
||||
-> docs/RESEARCH_LOG.md 축적
|
||||
-> 개요와 논점 정리
|
||||
-> docs/REPORT_DRAFT.md 초안 작성
|
||||
-> 사용자 피드백 요청
|
||||
-> docs/FEEDBACK.md 기록
|
||||
-> 추가 검색/수정 반복
|
||||
```
|
||||
|
||||
## 산출물 불변식
|
||||
- `docs/PRD.md`는 사용자 의도와 범위의 단일 진실 공급원이다.
|
||||
- `docs/RESEARCH_LOG.md`는 모든 핵심 사실과 출처의 단일 진실 공급원이다.
|
||||
- `docs/REPORT_DRAFT.md`에는 검증된 근거와 명시된 한계만 반영한다.
|
||||
- `docs/FEEDBACK.md`에는 피드백 원문, 해석, 반영 여부, 미반영 이유를 기록한다.
|
||||
- `phases/{phase}/index.json`은 단계 실행 상태의 단일 진실 공급원이다.
|
||||
|
||||
## 리서치 기록 단위
|
||||
각 출처는 아래 필드를 포함해야 한다.
|
||||
|
||||
```text
|
||||
- 주장/논점:
|
||||
- 출처 제목:
|
||||
- 발행기관:
|
||||
- URL:
|
||||
- 발행일:
|
||||
- 접근일:
|
||||
- 핵심 내용:
|
||||
- 신뢰도 메모:
|
||||
- 보고서 반영 위치:
|
||||
```
|
||||
|
||||
## 상태 관리
|
||||
- 보고서 상태는 별도 애플리케이션 상태가 아니라 문서 파일의 변경 이력으로 관리한다.
|
||||
- 단계 상태는 `phases/{phase}/index.json`의 `pending`, `completed`, `error`, `blocked`로 관리한다.
|
||||
- 사용자의 추가 판단이 필요한 경우 Codex는 추측하지 말고 해당 step을 `blocked`로 표시하고 `blocked_reason`을 기록한다.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Feedback Log
|
||||
|
||||
이 파일은 사용자 피드백, 해석, 반영 여부, 추가 리서치 필요 사항을 기록한다.
|
||||
|
||||
## Feedback 001
|
||||
- 날짜: {YYYY-MM-DD}
|
||||
- 피드백 원문: {사용자 피드백}
|
||||
- 해석: {보고서에 어떤 변경이 필요한지}
|
||||
- 반영 위치: {docs/REPORT_DRAFT.md 섹션명 또는 기타 파일}
|
||||
- 상태: {pending | applied | rejected | needs-research}
|
||||
- 미반영 이유: {해당 시 작성}
|
||||
|
||||
## 다음 반복에서 확인할 사항
|
||||
- {확인할 사항 1}
|
||||
- {확인할 사항 2}
|
||||
@@ -0,0 +1,54 @@
|
||||
# Report Brief: {보고서 제목}
|
||||
|
||||
## 입력 상태
|
||||
- 작성 상태: {draft | ready-for-research | ready-for-draft | revision}
|
||||
- 마지막 업데이트: {YYYY-MM-DD}
|
||||
- 작성자/요청자: {이름 또는 팀}
|
||||
|
||||
## 보고서 주제
|
||||
{무엇에 대한 보고서인지 한 문장으로 작성한다.}
|
||||
|
||||
## 용도
|
||||
{이 보고서가 사용될 의사결정, 회의, 내부 공유, 투자 검토, 전략 수립, 정책 검토, 기술 검토 등의 목적을 적는다.}
|
||||
|
||||
## 핵심 키워드
|
||||
- {키워드 1}
|
||||
- {키워드 2}
|
||||
- {키워드 3}
|
||||
- {산업, 기업, 지역, 기간, 제도, 기술, 이해관계자 등}
|
||||
|
||||
## 방향성
|
||||
{중립 분석, 찬반 비교, 시장 전망, 실행 전략, 리스크 중심, 정책 영향 분석, 기술 타당성 검토 등 원하는 논조와 분석 방향을 적는다.}
|
||||
|
||||
## 독자
|
||||
- 주요 독자: {임원, 실무자, 투자자, 고객, 정책 담당자 등}
|
||||
- 독자의 배경지식: {낮음 | 중간 | 높음}
|
||||
- 독자가 보고서로 결정해야 하는 것: {결정 또는 행동}
|
||||
|
||||
## 산출물 형식
|
||||
- 형식: {요약 보고서 | 심층 보고서 | 임원 브리프 | 조사 메모 | 백서 초안 | 발표자료용 원고}
|
||||
- 목표 분량: {예: 3쪽, 10쪽, 2,000자, 8개 섹션}
|
||||
- 언어와 문체: {예: 한국어, 전문적이고 간결한 문체}
|
||||
- 표/그림 요구: {예: 비교표 필수, 그래프 후보 제안, 이미지 불필요}
|
||||
|
||||
## 반드시 포함할 질문
|
||||
1. {질문 1}
|
||||
2. {질문 2}
|
||||
3. {질문 3}
|
||||
|
||||
## 제외 범위
|
||||
- {다루지 않을 내용 1}
|
||||
- {다루지 않을 내용 2}
|
||||
- {다루지 않을 내용 3}
|
||||
|
||||
## 성공 기준
|
||||
- {성공 기준 1: 예: 핵심 주장마다 출처가 있다}
|
||||
- {성공 기준 2: 예: 독자가 실행 결정을 내릴 수 있는 권고가 있다}
|
||||
- {성공 기준 3: 예: 불확실성과 반대 근거가 별도 정리되어 있다}
|
||||
|
||||
## 사용자 피드백 질문
|
||||
초안 작성 후 사용자에게 반드시 확인할 질문을 적는다.
|
||||
|
||||
1. {확인 질문 1}
|
||||
2. {확인 질문 2}
|
||||
3. {확인 질문 3}
|
||||
@@ -0,0 +1,36 @@
|
||||
# Report Draft: {보고서 제목}
|
||||
|
||||
## Executive Summary
|
||||
{핵심 결론과 권고를 3~5개 문장 또는 bullet로 작성한다.}
|
||||
|
||||
## 배경과 문제 정의
|
||||
{보고서 주제의 맥락, 독자가 알아야 할 배경, 분석 범위를 작성한다.}
|
||||
|
||||
## 핵심 발견사항
|
||||
1. {발견사항 1}
|
||||
2. {발견사항 2}
|
||||
3. {발견사항 3}
|
||||
|
||||
## 세부 분석
|
||||
{근거와 출처를 연결해 분석을 작성한다.}
|
||||
|
||||
## 리스크와 불확실성
|
||||
- {리스크 1}
|
||||
- {리스크 2}
|
||||
- {데이터 한계 또는 추가 확인 필요 사항}
|
||||
|
||||
## 선택지 또는 시나리오
|
||||
| 선택지 | 장점 | 단점 | 필요한 조건 |
|
||||
|--------|------|------|-------------|
|
||||
| {선택지 A} | {장점} | {단점} | {조건} |
|
||||
|
||||
## 권고안
|
||||
{보고서 목적에 맞는 판단, 실행 순서, 의사결정 포인트를 작성한다.}
|
||||
|
||||
## 참고 출처
|
||||
- {출처명, URL, 발행일 또는 접근일}
|
||||
|
||||
## 사용자 피드백 질문
|
||||
1. {초안 검토 질문 1}
|
||||
2. {초안 검토 질문 2}
|
||||
3. {초안 검토 질문 3}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Research Log
|
||||
|
||||
이 파일은 보고서 작성에 사용되는 웹 검색 결과와 검증 메모를 축적하는 공간이다. 출처 없는 핵심 주장은 보고서 본문에 단정적으로 반영하지 않는다.
|
||||
|
||||
## 리서치 질문
|
||||
1. {질문 1}
|
||||
2. {질문 2}
|
||||
3. {질문 3}
|
||||
|
||||
## 출처 목록
|
||||
|
||||
### Source 001: {출처 제목}
|
||||
- 주장/논점: {이 출처가 뒷받침하거나 반박하는 주장}
|
||||
- 발행기관: {기관명}
|
||||
- URL: {https://...}
|
||||
- 발행일: {YYYY-MM-DD 또는 알 수 없음}
|
||||
- 접근일: {YYYY-MM-DD}
|
||||
- 핵심 내용: {보고서에 필요한 내용 요약}
|
||||
- 신뢰도 메모: {1차 자료/공식 문서/언론/블로그/이해관계 여부}
|
||||
- 보고서 반영 위치: {섹션명 또는 미반영}
|
||||
|
||||
## 상충 근거
|
||||
| 쟁점 | 출처 A | 출처 B | 해석 |
|
||||
|------|--------|--------|------|
|
||||
| {쟁점} | {요약} | {요약} | {어떻게 다룰지} |
|
||||
|
||||
## 검증 필요
|
||||
- {출처가 부족하거나 추가 확인이 필요한 주장}
|
||||
|
||||
## 폐기한 주장
|
||||
- {근거 부족, 오래된 자료, 방향성 불일치 등으로 제외한 내용과 이유}
|
||||
@@ -0,0 +1,53 @@
|
||||
# 보고서 스타일 가이드
|
||||
|
||||
## 작성 원칙
|
||||
1. 독자가 먼저 결론을 파악하고, 바로 근거와 한계를 확인할 수 있게 쓴다.
|
||||
2. 핵심 주장은 출처, 수치, 비교 기준 중 하나 이상으로 뒷받침한다.
|
||||
3. 불확실성, 반대 근거, 데이터 한계는 숨기지 않고 별도 문단으로 정리한다.
|
||||
4. 사용자의 방향성을 따르되 근거와 충돌하면 충돌 사실을 명시한다.
|
||||
|
||||
## 기본 구조
|
||||
보고서 형식이 별도로 지정되지 않았다면 아래 구조를 사용한다.
|
||||
|
||||
1. Executive Summary
|
||||
2. 배경과 문제 정의
|
||||
3. 핵심 발견사항
|
||||
4. 세부 분석
|
||||
5. 리스크와 불확실성
|
||||
6. 선택지 또는 시나리오
|
||||
7. 권고안
|
||||
8. 참고 출처
|
||||
9. 사용자 피드백 질문
|
||||
|
||||
## 문체
|
||||
- 한국어 기본 문체는 전문적이고 간결하게 쓴다.
|
||||
- 과장 표현, 광고 문구, 막연한 낙관론을 피한다.
|
||||
- `중요하다`, `빠르게 성장한다`, `리스크가 크다` 같은 표현은 가능한 한 근거를 붙인다.
|
||||
- 숫자는 단위와 기준 시점을 함께 쓴다.
|
||||
- 상대 날짜는 절대 날짜로 풀어 쓴다. 예: `최근` 대신 `2025년 4분기 이후`.
|
||||
|
||||
## 표와 목록
|
||||
- 비교, 장단점, 출처 충돌, 시나리오 분석은 표를 우선 사용한다.
|
||||
- 5개 이상의 병렬 항목은 목록으로 정리한다.
|
||||
- 표에는 비교 기준을 명확히 적는다.
|
||||
|
||||
## 출처 표기
|
||||
- 본문에는 간결한 출처명을 적고, 상세 정보는 `참고 출처` 또는 `docs/RESEARCH_LOG.md`에 둔다.
|
||||
- URL만 나열하지 말고 출처가 뒷받침하는 주장을 함께 적는다.
|
||||
- 출처가 오래되었거나 이해관계가 있으면 신뢰도 메모를 남긴다.
|
||||
|
||||
## 하지 말 것
|
||||
| 금지 사항 | 이유 |
|
||||
|-----------|------|
|
||||
| 출처 없는 수치 단정 | 검증 불가능한 보고서가 됨 |
|
||||
| 검색 결과 상위 문서만 요약 | 편향과 최신성 오류 가능성이 큼 |
|
||||
| 결론부터 정하고 근거를 끼워 맞춤 | 분석 신뢰도를 떨어뜨림 |
|
||||
| 사용자가 준 키워드 누락 | 입력 의도와 산출물이 어긋남 |
|
||||
| 피드백 반영 여부 미기록 | 반복 작업에서 맥락이 사라짐 |
|
||||
|
||||
## 최종 점검
|
||||
- 보고서 주제, 용도, 키워드, 방향성이 본문에 반영되었는가?
|
||||
- 핵심 주장마다 출처 또는 검증 메모가 있는가?
|
||||
- 상충 근거와 한계를 다루었는가?
|
||||
- 독자가 취할 수 있는 다음 행동이 분명한가?
|
||||
- 사용자에게 확인할 피드백 질문이 남아 있는가?
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"project": "{보고서 프로젝트명}",
|
||||
"phase": "0-report-generation",
|
||||
"steps": [
|
||||
{
|
||||
"step": 0,
|
||||
"name": "brief-audit",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"step": 1,
|
||||
"name": "source-discovery",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"step": 2,
|
||||
"name": "deep-research",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"step": 3,
|
||||
"name": "source-review-outline",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"step": 4,
|
||||
"name": "draft-report",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"step": 5,
|
||||
"name": "feedback-setup",
|
||||
"status": "pending"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# Step 0: brief-audit
|
||||
|
||||
## Read First
|
||||
- /AGENTS.md
|
||||
- /docs/PRD.md
|
||||
- /docs/ARCHITECTURE.md
|
||||
- /docs/ADR.md
|
||||
- /docs/UI_GUIDE.md
|
||||
- /docs/RESEARCH_LOG.md
|
||||
- /docs/REPORT_DRAFT.md
|
||||
- /docs/FEEDBACK.md
|
||||
|
||||
## Task
|
||||
Audit `docs/PRD.md` for the minimum report inputs: topic, purpose, keywords, direction, audience, output format, required questions, exclusions, and success criteria.
|
||||
|
||||
If the brief is usable, update `docs/RESEARCH_LOG.md` with a concrete research question list derived from the brief. If a critical input is still a placeholder or empty, update this step in `phases/0-report-generation/index.json` as `blocked` with a concise `blocked_reason` and stop.
|
||||
|
||||
## Acceptance Criteria
|
||||
```bash
|
||||
python scripts/validate_workspace.py
|
||||
```
|
||||
|
||||
## Verification
|
||||
1. Run the acceptance command.
|
||||
2. Confirm the report brief has enough information to guide research.
|
||||
3. Confirm `docs/RESEARCH_LOG.md` contains research questions aligned with the brief.
|
||||
4. Update this step in `phases/0-report-generation/index.json`:
|
||||
- `completed` + `summary`
|
||||
- `blocked` + `blocked_reason`
|
||||
- `error` + `error_message`
|
||||
|
||||
## Do Not
|
||||
- Do not invent missing report intent because later research will follow the wrong direction.
|
||||
- Do not start drafting the report in this step.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Step 1: source-discovery
|
||||
|
||||
## Read First
|
||||
- /AGENTS.md
|
||||
- /docs/PRD.md
|
||||
- /docs/ARCHITECTURE.md
|
||||
- /docs/ADR.md
|
||||
- /docs/UI_GUIDE.md
|
||||
- /docs/RESEARCH_LOG.md
|
||||
- /phases/0-report-generation/index.json
|
||||
|
||||
## Task
|
||||
Perform broad web search for the report topic, purpose, and keywords in `docs/PRD.md`.
|
||||
|
||||
Update `docs/RESEARCH_LOG.md` with candidate sources. Prioritize official documents, primary sources, public data, academic or government sources, credible industry research, and reputable journalism. Record each source with title, publisher, URL, publication date or access date, key content, reliability note, and likely report section.
|
||||
|
||||
Aim for enough source diversity to cover the main claims, counterarguments, and uncertainty. If web access is unavailable or a required source is behind authentication/paywall, mark the step `blocked` with the reason.
|
||||
|
||||
## Acceptance Criteria
|
||||
```bash
|
||||
python scripts/validate_workspace.py
|
||||
```
|
||||
|
||||
## Verification
|
||||
1. Run the acceptance command.
|
||||
2. Confirm `docs/RESEARCH_LOG.md` contains source entries with URLs and reliability notes.
|
||||
3. Confirm weak or unverified claims are listed under `검증 필요`.
|
||||
4. Update this step in `phases/0-report-generation/index.json`.
|
||||
|
||||
## Do Not
|
||||
- Do not use model memory as a substitute for web search.
|
||||
- Do not copy long source text into the repository; summarize only what is needed.
|
||||
- Do not add unsupported claims to `docs/REPORT_DRAFT.md` in this step.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Step 2: deep-research
|
||||
|
||||
## Read First
|
||||
- /AGENTS.md
|
||||
- /docs/PRD.md
|
||||
- /docs/ARCHITECTURE.md
|
||||
- /docs/ADR.md
|
||||
- /docs/UI_GUIDE.md
|
||||
- /docs/RESEARCH_LOG.md
|
||||
- /phases/0-report-generation/index.json
|
||||
|
||||
## Task
|
||||
Deepen the research around the most important keywords, stakeholders, time periods, regions, and required questions from `docs/PRD.md`.
|
||||
|
||||
Use web search to fill evidence gaps, find current data, and look for conflicting viewpoints. Update:
|
||||
|
||||
- `docs/RESEARCH_LOG.md` source list with additional source entries.
|
||||
- `docs/RESEARCH_LOG.md` `상충 근거` table when sources disagree.
|
||||
- `docs/RESEARCH_LOG.md` `검증 필요` for claims that still lack enough support.
|
||||
- `docs/RESEARCH_LOG.md` `폐기한 주장` for claims excluded because the evidence is weak, outdated, or outside scope.
|
||||
|
||||
## Acceptance Criteria
|
||||
```bash
|
||||
python scripts/validate_workspace.py
|
||||
```
|
||||
|
||||
## Verification
|
||||
1. Run the acceptance command.
|
||||
2. Confirm the required questions in `docs/PRD.md` have research coverage or explicit gaps.
|
||||
3. Confirm conflicts and uncertainty are visible in `docs/RESEARCH_LOG.md`.
|
||||
4. Update this step in `phases/0-report-generation/index.json`.
|
||||
|
||||
## Do Not
|
||||
- Do not resolve conflicting evidence by ignoring one side.
|
||||
- Do not overfit research to a predetermined conclusion.
|
||||
- Do not write the final report body before the source review and outline step.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Step 3: source-review-outline
|
||||
|
||||
## Read First
|
||||
- /AGENTS.md
|
||||
- /docs/PRD.md
|
||||
- /docs/ARCHITECTURE.md
|
||||
- /docs/ADR.md
|
||||
- /docs/UI_GUIDE.md
|
||||
- /docs/RESEARCH_LOG.md
|
||||
- /docs/REPORT_DRAFT.md
|
||||
- /phases/0-report-generation/index.json
|
||||
|
||||
## Task
|
||||
Review `docs/RESEARCH_LOG.md` for source quality, source freshness, conflicts, weak claims, and missing evidence. Then create or refine the report outline in `docs/REPORT_DRAFT.md`.
|
||||
|
||||
The outline must reflect the report purpose, keywords, direction, audience, and output format from `docs/PRD.md`. Include placeholders for key claims, supporting sources, risks, uncertainty, and feedback questions.
|
||||
|
||||
## Acceptance Criteria
|
||||
```bash
|
||||
python scripts/validate_workspace.py
|
||||
```
|
||||
|
||||
## Verification
|
||||
1. Run the acceptance command.
|
||||
2. Confirm the outline follows `docs/UI_GUIDE.md`.
|
||||
3. Confirm major sections have evidence sources or explicit evidence gaps.
|
||||
4. Update this step in `phases/0-report-generation/index.json`.
|
||||
|
||||
## Do Not
|
||||
- Do not hide source weaknesses because they are inconvenient.
|
||||
- Do not delete useful research history from `docs/RESEARCH_LOG.md`.
|
||||
- Do not write polished conclusions that are not supported by the reviewed sources.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Step 4: draft-report
|
||||
|
||||
## Read First
|
||||
- /AGENTS.md
|
||||
- /docs/PRD.md
|
||||
- /docs/ARCHITECTURE.md
|
||||
- /docs/ADR.md
|
||||
- /docs/UI_GUIDE.md
|
||||
- /docs/RESEARCH_LOG.md
|
||||
- /docs/REPORT_DRAFT.md
|
||||
- /phases/0-report-generation/index.json
|
||||
|
||||
## Task
|
||||
Write or revise `docs/REPORT_DRAFT.md` into a coherent report draft.
|
||||
|
||||
Use only claims supported by `docs/RESEARCH_LOG.md` or clearly label unresolved claims as uncertain. Follow the target format, audience, direction, and success criteria in `docs/PRD.md`. Include executive summary, background, core findings, detailed analysis, risks and uncertainty, options or scenarios, recommendations, references, and user feedback questions unless the brief specifies a different structure.
|
||||
|
||||
## Acceptance Criteria
|
||||
```bash
|
||||
python scripts/validate_workspace.py
|
||||
```
|
||||
|
||||
## Verification
|
||||
1. Run the acceptance command.
|
||||
2. Confirm every core claim in the draft maps to a source or a `검증 필요` note.
|
||||
3. Confirm the draft includes risks, uncertainty, and next-decision guidance.
|
||||
4. Update this step in `phases/0-report-generation/index.json`.
|
||||
|
||||
## Do Not
|
||||
- Do not include source-free statistics or current claims.
|
||||
- Do not use persuasive language that exceeds the evidence.
|
||||
- Do not remove feedback questions from the end of the draft.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Step 5: feedback-setup
|
||||
|
||||
## Read First
|
||||
- /AGENTS.md
|
||||
- /docs/PRD.md
|
||||
- /docs/ARCHITECTURE.md
|
||||
- /docs/ADR.md
|
||||
- /docs/UI_GUIDE.md
|
||||
- /docs/RESEARCH_LOG.md
|
||||
- /docs/REPORT_DRAFT.md
|
||||
- /docs/FEEDBACK.md
|
||||
- /phases/0-report-generation/index.json
|
||||
|
||||
## Task
|
||||
Prepare the report for user review.
|
||||
|
||||
Update `docs/REPORT_DRAFT.md` with focused feedback questions that help the user decide the next iteration. Update `docs/FEEDBACK.md` with a new pending feedback entry that names the draft version, review focus, and next questions.
|
||||
|
||||
If the report still has material evidence gaps, list them clearly in both `docs/REPORT_DRAFT.md` and `docs/FEEDBACK.md`.
|
||||
|
||||
## Acceptance Criteria
|
||||
```bash
|
||||
python scripts/validate_workspace.py
|
||||
```
|
||||
|
||||
## Verification
|
||||
1. Run the acceptance command.
|
||||
2. Confirm `docs/REPORT_DRAFT.md` is ready for user review.
|
||||
3. Confirm `docs/FEEDBACK.md` has a pending entry for the next iteration.
|
||||
4. Update this step in `phases/0-report-generation/index.json`.
|
||||
|
||||
## Do Not
|
||||
- Do not mark unresolved evidence gaps as finished.
|
||||
- Do not ask generic feedback questions that do not help revise the report.
|
||||
- Do not start a second revision phase in this step.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"phases": [
|
||||
{
|
||||
"dir": "0-report-generation",
|
||||
"status": "pending"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "harness-engineering",
|
||||
"version": "1.0.0",
|
||||
"description": "Repo-local Report Harness slash commands for Codex.",
|
||||
"interface": {
|
||||
"displayName": "Report Harness",
|
||||
"shortDescription": "Report planning and review prompts for this repo",
|
||||
"longDescription": "Optional local plugin that exposes Report Harness slash commands while the core workflow remains in repo-native AGENTS, skills, custom agents, and hooks.",
|
||||
"developerName": "Local Repository",
|
||||
"category": "Productivity",
|
||||
"capabilities": [
|
||||
"Interactive",
|
||||
"Read",
|
||||
"Write"
|
||||
],
|
||||
"defaultPrompt": [
|
||||
"Use Report Harness to plan a new research, drafting, or revision phase for this repository.",
|
||||
"Review my report artifacts against the Report Harness docs and rules."
|
||||
],
|
||||
"brandColor": "#2563EB"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Report Harness"
|
||||
short_description: "Use report slash commands in this repository"
|
||||
default_prompt: "Use Report Harness to plan a research phase or review report artifacts in this repository."
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
description: Run the Report Harness planning workflow for this repository.
|
||||
---
|
||||
|
||||
# /harness
|
||||
|
||||
## Preflight
|
||||
|
||||
- Read `/AGENTS.md`, `/docs/PRD.md`, `/docs/ARCHITECTURE.md`, `/docs/ADR.md`, `/docs/UI_GUIDE.md`, `/docs/RESEARCH_LOG.md`, `/docs/REPORT_DRAFT.md`, and `/docs/FEEDBACK.md` if they exist.
|
||||
- Confirm whether the user wants discussion only, a draft report plan, or file generation under `phases/`.
|
||||
- Check that the report topic, purpose, keywords, direction, audience, and output format are available.
|
||||
- Note whether the user explicitly asked for subagents; only then consider `phase_planner` or built-in explorers/workers.
|
||||
|
||||
## Plan
|
||||
|
||||
- State what will be created or updated before editing files.
|
||||
- If a plan already exists under `phases/`, say whether you are extending it or replacing part of it.
|
||||
- Keep each proposed step small, self-contained, and independently executable.
|
||||
- Separate source discovery, deep research, source review, outline, draft, and feedback steps.
|
||||
|
||||
## Commands
|
||||
|
||||
- Invoke `$harness-workflow` and follow it.
|
||||
- When file generation is requested, create or update:
|
||||
- `phases/index.json`
|
||||
- `phases/{phase}/index.json`
|
||||
- `phases/{phase}/stepN.md`
|
||||
- Use `python scripts/execute.py <phase>` as the runtime target when you need to reference execution.
|
||||
|
||||
## Verification
|
||||
|
||||
- Re-read the generated phase files for consistency.
|
||||
- Check that step numbering, phase names, report artifacts, and acceptance commands line up.
|
||||
- Confirm that research steps require web search and `docs/RESEARCH_LOG.md` updates.
|
||||
- If the repo has a validator, prefer `python scripts/validate_workspace.py` as the default acceptance command unless the user specified a narrower command.
|
||||
|
||||
## Summary
|
||||
|
||||
## Result
|
||||
- **Action**: planned or generated Report Harness phase files
|
||||
- **Status**: success | partial | failed
|
||||
- **Details**: phase name, step count, report artifacts, and any blockers
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Suggest the next natural command, usually `python scripts/execute.py <phase>` or a focused edit to one generated step.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
description: Review report changes against Report Harness repository rules and docs.
|
||||
---
|
||||
|
||||
# /review
|
||||
|
||||
## Preflight
|
||||
|
||||
- Read `/AGENTS.md`, `/docs/PRD.md`, `/docs/ARCHITECTURE.md`, `/docs/ADR.md`, `/docs/UI_GUIDE.md`, `/docs/RESEARCH_LOG.md`, `/docs/REPORT_DRAFT.md`, and `/docs/FEEDBACK.md` if they exist.
|
||||
- Identify the changed report files, research logs, feedback files, or generated `phases/` artifacts that need review.
|
||||
- If the user wants a delegated review, use the read-only custom agent `harness_reviewer` only when they explicitly asked for subagents.
|
||||
|
||||
## Plan
|
||||
|
||||
- State what evidence will be checked: brief alignment, research coverage, source quality, unsupported claims, feedback handling, generated phase files, and validation output if available.
|
||||
- Prioritize factual support, source freshness, CRITICAL rule violations, and missing counterarguments over style commentary.
|
||||
|
||||
## Commands
|
||||
|
||||
- Invoke `$harness-review`.
|
||||
- Use Codex built-in `/review` when the user specifically wants a review-style pass over the working tree or generated report artifacts.
|
||||
- If validation is relevant, run `python scripts/validate_workspace.py` or explain why it was not run.
|
||||
|
||||
## Verification
|
||||
|
||||
- Confirm that every finding is tied to a file and an actual rule or report-quality risk.
|
||||
- If no findings remain, say so explicitly and mention residual risks or missing evidence.
|
||||
|
||||
## Summary
|
||||
|
||||
## Result
|
||||
- **Action**: reviewed Report Harness changes
|
||||
- **Status**: success | partial | failed
|
||||
- **Details**: findings, docs checked, and validation status
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Suggest the smallest follow-up: fix the top finding, rerun validation, or execute a pending phase.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,447 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Report Harness Step Executor - run report phase steps sequentially with Codex and self-correction.
|
||||
|
||||
Usage:
|
||||
python scripts/execute.py <phase-dir> [--push]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import types
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def progress_indicator(label: str):
|
||||
"""터미널 진행 표시기. with 문으로 사용하며 .elapsed 로 경과 시간을 읽는다."""
|
||||
frames = "◐◓◑◒"
|
||||
stop = threading.Event()
|
||||
t0 = time.monotonic()
|
||||
|
||||
def _animate():
|
||||
idx = 0
|
||||
while not stop.wait(0.12):
|
||||
sec = int(time.monotonic() - t0)
|
||||
sys.stderr.write(f"\r{frames[idx % len(frames)]} {label} [{sec}s]")
|
||||
sys.stderr.flush()
|
||||
idx += 1
|
||||
sys.stderr.write("\r" + " " * (len(label) + 20) + "\r")
|
||||
sys.stderr.flush()
|
||||
|
||||
th = threading.Thread(target=_animate, daemon=True)
|
||||
th.start()
|
||||
info = types.SimpleNamespace(elapsed=0.0)
|
||||
try:
|
||||
yield info
|
||||
finally:
|
||||
stop.set()
|
||||
th.join()
|
||||
info.elapsed = time.monotonic() - t0
|
||||
|
||||
|
||||
class StepExecutor:
|
||||
"""Phase 디렉토리 안의 step들을 순차 실행하는 하네스."""
|
||||
|
||||
MAX_RETRIES = 3
|
||||
DOCS_MSG = "docs({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
|
||||
self._git_enabled: Optional[bool] = None
|
||||
|
||||
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 _ensure_git_enabled(self) -> bool:
|
||||
if self._git_enabled is not None:
|
||||
return self._git_enabled
|
||||
|
||||
r = self._run_git("rev-parse", "--is-inside-work-tree")
|
||||
self._git_enabled = r.returncode == 0 and r.stdout.strip() == "true"
|
||||
if not self._git_enabled:
|
||||
print(" WARN: git 저장소가 아니므로 브랜치/커밋 관리를 건너뜁니다.")
|
||||
return self._git_enabled
|
||||
|
||||
def _checkout_branch(self):
|
||||
if not self._ensure_git_enabled():
|
||||
return
|
||||
|
||||
branch = f"report-{self._phase_name}"
|
||||
|
||||
r = self._run_git("rev-parse", "--abbrev-ref", "HEAD")
|
||||
if r.returncode != 0:
|
||||
print(f" ERROR: 현재 브랜치를 확인할 수 없습니다.")
|
||||
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):
|
||||
if not self._ensure_git_enabled():
|
||||
return
|
||||
|
||||
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.DOCS_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} 보고서 프로젝트의 리서치/작성 에이전트입니다. 아래 step을 수행하세요.\n\n"
|
||||
f"{guardrails}\n\n---\n\n"
|
||||
f"{step_context}{retry_section}"
|
||||
f"## 작업 규칙\n\n"
|
||||
f"1. 이전 step 산출물과 `docs/*.md` 문서를 확인하고 보고서 맥락을 유지하라.\n"
|
||||
f"2. 이 step에 명시된 리서치, 출처 검토, 작성, 피드백 작업만 수행하라. 범위 밖 산출물을 만들지 마라.\n"
|
||||
f"3. 사실 주장, 수치, 최신 동향, 법/정책/시장 정보는 웹 검색으로 확인하고 출처를 `docs/RESEARCH_LOG.md`에 기록하라.\n"
|
||||
f"4. 출처가 부족한 핵심 주장은 본문에 단정하지 말고 `docs/RESEARCH_LOG.md`의 `검증 필요`에 남겨라.\n"
|
||||
f"5. AC(Acceptance Criteria) 검증을 직접 실행하라.\n"
|
||||
f"6. /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" - 사용자 개입이 필요한 경우 (보고서 방향성 누락, 유료 자료 접근, 인증, 수동 판단 등) → \"blocked\" + \"blocked_reason\" 기록 후 즉시 중단\n"
|
||||
f"7. 변경사항은 워킹 트리에 남겨라. step 완료 후 커밋은 execute.py가 정리한다.\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")
|
||||
last_message_path = self._phase_dir / f"step{step_num}-last-message.txt"
|
||||
result = subprocess.run(
|
||||
["codex", "exec", "--full-auto", "--json", "-C", self._root, "-o", str(last_message_path)],
|
||||
cwd=self._root,
|
||||
input=prompt,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=1800,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"\n WARN: Codex가 비정상 종료됨 (code {result.returncode})")
|
||||
if result.stderr:
|
||||
print(f" stderr: {result.stderr[:500]}")
|
||||
|
||||
final_message = None
|
||||
if last_message_path.exists():
|
||||
final_message = last_message_path.read_text(encoding="utf-8")
|
||||
|
||||
output = {
|
||||
"step": step_num, "name": step_name,
|
||||
"exitCode": result.returncode,
|
||||
"finalMessage": final_message,
|
||||
"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" Report 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 report 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")
|
||||
|
||||
if self._ensure_git_enabled():
|
||||
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:
|
||||
if not self._ensure_git_enabled():
|
||||
print(" WARN: git 저장소가 아니므로 push를 건너뜁니다.")
|
||||
return
|
||||
|
||||
branch = f"report-{self._phase_name}"
|
||||
r = self._run_git("push", "-u", "origin", branch)
|
||||
if r.returncode != 0:
|
||||
print(f"\n ERROR: git push 실패: {r.stderr.strip()}")
|
||||
sys.exit(1)
|
||||
print(f" ✓ Pushed to origin/{branch}")
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" Phase '{self._phase_name}' completed!")
|
||||
print(f"{'='*60}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Harness Step Executor")
|
||||
parser.add_argument("phase_dir", help="Phase directory name (e.g. 0-mvp)")
|
||||
parser.add_argument("--push", action="store_true", help="Push branch after completion")
|
||||
args = parser.parse_args()
|
||||
|
||||
StepExecutor(args.phase_dir, auto_push=args.push).run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,578 @@
|
||||
"""execute.py Report Harness safety-net tests."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, 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")
|
||||
|
||||
docs_dir = tmp_path / "docs"
|
||||
docs_dir.mkdir()
|
||||
(docs_dir / "arch.md").write_text("# Architecture\nSome content")
|
||||
(docs_dir / "guide.md").write_text("# Guide\nAnother doc")
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def phase_dir(tmp_project):
|
||||
"""step 3개를 가진 report phase 디렉토리."""
|
||||
d = tmp_project / "phases" / "0-mvp"
|
||||
d.mkdir()
|
||||
|
||||
index = {
|
||||
"project": "TestProject",
|
||||
"phase": "mvp",
|
||||
"steps": [
|
||||
{"step": 0, "name": "brief-audit", "status": "completed", "summary": "보고서 브리프 점검 완료"},
|
||||
{"step": 1, "name": "deep-research", "status": "completed", "summary": "핵심 리서치 완료"},
|
||||
{"step": 2, "name": "draft-report", "status": "pending"},
|
||||
],
|
||||
}
|
||||
(d / "index.json").write_text(json.dumps(index, indent=2, ensure_ascii=False))
|
||||
(d / "step2.md").write_text("# Step 2: draft-report\n\n보고서 초안을 작성하세요.")
|
||||
|
||||
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))
|
||||
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()
|
||||
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()
|
||||
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))
|
||||
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())
|
||||
result = ex.StepExecutor._build_step_context(index)
|
||||
assert "Step 0 (brief-audit): 보고서 브리프 점검 완료" in result
|
||||
assert "Step 1 (deep-research): 핵심 리서치 완료" in result
|
||||
|
||||
def test_excludes_pending(self, phase_dir):
|
||||
index = json.loads((phase_dir / "index.json").read_text())
|
||||
result = ex.StepExecutor._build_step_context(index)
|
||||
assert "draft-report" not in result
|
||||
|
||||
def test_excludes_completed_without_summary(self, phase_dir):
|
||||
index = json.loads((phase_dir / "index.json").read_text())
|
||||
del index["steps"][0]["summary"]
|
||||
result = ex.StepExecutor._build_step_context(index)
|
||||
assert "brief-audit" not in result
|
||||
assert "deep-research" 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())
|
||||
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_mentions_executor_commits(self, executor):
|
||||
result = executor._build_preamble("", "")
|
||||
assert "커밋은 execute.py가 정리한다" in result
|
||||
|
||||
def test_mentions_report_agent_role(self, executor):
|
||||
result = executor._build_preamble("", "")
|
||||
assert "리서치/작성 에이전트" in result
|
||||
|
||||
def test_requires_source_logging(self, executor):
|
||||
result = executor._build_preamble("", "")
|
||||
assert "docs/RESEARCH_LOG.md" in result
|
||||
assert "출처" 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())
|
||||
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())
|
||||
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())
|
||||
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())
|
||||
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())
|
||||
executor._update_top_index("completed")
|
||||
after = json.loads(top_index.read_text())
|
||||
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="true\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="report-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="true\n", stderr=""),
|
||||
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="true\n", stderr=""),
|
||||
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="true\n", stderr=""),
|
||||
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_skips_branch_management(self, executor):
|
||||
self._mock_git(executor, [
|
||||
MagicMock(returncode=1, stdout="", stderr="not a git repo"),
|
||||
])
|
||||
executor._checkout_branch()
|
||||
assert executor._git_enabled is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _commit_step (mocked)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCommitStep:
|
||||
def test_two_phase_commit(self, executor):
|
||||
calls = []
|
||||
def fake_git(*args):
|
||||
calls.append(args)
|
||||
if args == ("rev-parse", "--is-inside-work-tree"):
|
||||
return MagicMock(returncode=0, stdout="true\n", stderr="")
|
||||
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 "docs(mvp):" in commit_calls[0][2]
|
||||
assert "chore(mvp):" in commit_calls[1][2]
|
||||
|
||||
def test_no_report_changes_skips_docs_commit(self, executor):
|
||||
call_count = {"diff": 0}
|
||||
calls = []
|
||||
def fake_git(*args):
|
||||
calls.append(args)
|
||||
if args == ("rev-parse", "--is-inside-work-tree"):
|
||||
return MagicMock(returncode=0, stdout="true\n", stderr="")
|
||||
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]
|
||||
kwargs = mock_run.call_args[1]
|
||||
assert cmd[0] == "codex"
|
||||
assert cmd[1] == "exec"
|
||||
assert "--full-auto" in cmd
|
||||
assert "--json" in cmd
|
||||
assert "-o" in cmd
|
||||
assert "PREAMBLE" in kwargs["input"]
|
||||
assert "보고서 초안을 작성하세요" in kwargs["input"]
|
||||
assert output["finalMessage"] is None
|
||||
|
||||
def test_saves_output_json(self, executor):
|
||||
def fake_run(*args, **kwargs):
|
||||
cmd = args[0]
|
||||
last_message_path = Path(cmd[cmd.index("-o") + 1])
|
||||
last_message_path.write_text("completed", encoding="utf-8")
|
||||
return MagicMock(returncode=0, stdout='{"ok": true}', stderr="")
|
||||
|
||||
step = {"step": 2, "name": "ui"}
|
||||
|
||||
with patch("subprocess.run", side_effect=fake_run):
|
||||
executor._invoke_codex(step, "preamble")
|
||||
|
||||
output_file = executor._phase_dir / "step2-output.json"
|
||||
assert output_file.exists()
|
||||
data = json.loads(output_file.read_text())
|
||||
assert data["step"] == 2
|
||||
assert data["name"] == "ui"
|
||||
assert data["exitCode"] == 0
|
||||
assert data["finalMessage"] == "completed"
|
||||
|
||||
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))
|
||||
|
||||
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,45 @@
|
||||
"""validate_workspace.py Report Harness tests."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
import validate_workspace as vw
|
||||
|
||||
|
||||
def write_required_docs(root: Path):
|
||||
docs = root / "docs"
|
||||
docs.mkdir()
|
||||
contents = {
|
||||
"PRD.md": "# Brief\n\n## 보고서 주제\n\n## 용도\n\n## 핵심 키워드\n\n## 방향성\n",
|
||||
"ARCHITECTURE.md": "# Architecture\n",
|
||||
"ADR.md": "# ADR\n",
|
||||
"UI_GUIDE.md": "# Style\n",
|
||||
"RESEARCH_LOG.md": "# Research\n\n## 출처 목록\n\n## 검증 필요\n",
|
||||
"REPORT_DRAFT.md": "# Draft\n\n## Executive Summary\n\n## 참고 출처\n\n## 사용자 피드백 질문\n",
|
||||
"FEEDBACK.md": "# Feedback\n\n## 다음 반복에서 확인할 사항\n",
|
||||
}
|
||||
for filename, text in contents.items():
|
||||
(docs / filename).write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def test_validate_report_docs_success(tmp_path):
|
||||
write_required_docs(tmp_path)
|
||||
|
||||
assert vw.validate_report_docs(tmp_path) == []
|
||||
|
||||
|
||||
def test_validate_report_docs_reports_missing_doc(tmp_path):
|
||||
write_required_docs(tmp_path)
|
||||
(tmp_path / "docs" / "RESEARCH_LOG.md").unlink()
|
||||
|
||||
assert "Missing docs/RESEARCH_LOG.md." in vw.validate_report_docs(tmp_path)
|
||||
|
||||
|
||||
def test_validate_report_docs_reports_missing_section(tmp_path):
|
||||
write_required_docs(tmp_path)
|
||||
(tmp_path / "docs" / "PRD.md").write_text("# Brief\n\n## 보고서 주제\n", encoding="utf-8")
|
||||
|
||||
errors = vw.validate_report_docs(tmp_path)
|
||||
|
||||
assert "docs/PRD.md missing section: ## 용도" in errors
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run repository validation commands for the Report Harness template."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_NPM_ORDER = ("lint", "build", "test")
|
||||
REQUIRED_DOCS = (
|
||||
"PRD.md",
|
||||
"ARCHITECTURE.md",
|
||||
"ADR.md",
|
||||
"UI_GUIDE.md",
|
||||
"RESEARCH_LOG.md",
|
||||
"REPORT_DRAFT.md",
|
||||
"FEEDBACK.md",
|
||||
)
|
||||
REQUIRED_SECTIONS = {
|
||||
"PRD.md": (
|
||||
"## 보고서 주제",
|
||||
"## 용도",
|
||||
"## 핵심 키워드",
|
||||
"## 방향성",
|
||||
),
|
||||
"RESEARCH_LOG.md": (
|
||||
"## 출처 목록",
|
||||
"## 검증 필요",
|
||||
),
|
||||
"REPORT_DRAFT.md": (
|
||||
"## Executive Summary",
|
||||
"## 참고 출처",
|
||||
"## 사용자 피드백 질문",
|
||||
),
|
||||
"FEEDBACK.md": (
|
||||
"## 다음 반복에서 확인할 사항",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def load_env_commands() -> list[str]:
|
||||
raw = os.environ.get("HARNESS_VALIDATION_COMMANDS", "")
|
||||
return [line.strip() for line in raw.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def load_npm_commands(root: Path) -> list[str]:
|
||||
package_json = root / "package.json"
|
||||
if not package_json.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
payload = json.loads(package_json.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
scripts = payload.get("scripts", {})
|
||||
if not isinstance(scripts, dict):
|
||||
return []
|
||||
|
||||
commands = []
|
||||
for name in DEFAULT_NPM_ORDER:
|
||||
value = scripts.get(name)
|
||||
if isinstance(value, str) and value.strip():
|
||||
commands.append(f"npm run {name}")
|
||||
return commands
|
||||
|
||||
|
||||
def discover_commands(root: Path) -> list[str]:
|
||||
env_commands = load_env_commands()
|
||||
if env_commands:
|
||||
return env_commands
|
||||
return load_npm_commands(root)
|
||||
|
||||
|
||||
def validate_report_docs(root: Path) -> list[str]:
|
||||
errors: list[str] = []
|
||||
docs_dir = root / "docs"
|
||||
if not docs_dir.is_dir():
|
||||
return ["Missing docs/ directory."]
|
||||
|
||||
for filename in REQUIRED_DOCS:
|
||||
path = docs_dir / filename
|
||||
if not path.exists():
|
||||
errors.append(f"Missing docs/{filename}.")
|
||||
continue
|
||||
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if not text.strip():
|
||||
errors.append(f"docs/{filename} is empty.")
|
||||
continue
|
||||
|
||||
for heading in REQUIRED_SECTIONS.get(filename, ()):
|
||||
if heading not in text:
|
||||
errors.append(f"docs/{filename} missing section: {heading}")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def run_command(command: str, root: Path) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
command,
|
||||
cwd=root,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def emit_stream(prefix: str, content: str, *, stream) -> None:
|
||||
text = content.strip()
|
||||
if not text:
|
||||
return
|
||||
print(prefix, file=stream)
|
||||
print(text, file=stream)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
doc_errors = validate_report_docs(root)
|
||||
if doc_errors:
|
||||
print("Report template validation failed:", file=sys.stderr)
|
||||
for error in doc_errors:
|
||||
print(f"- {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
commands = discover_commands(root)
|
||||
|
||||
if not commands:
|
||||
print("Report template validation succeeded.")
|
||||
print("No extra validation commands configured.")
|
||||
print("Set HARNESS_VALIDATION_COMMANDS or add npm scripts for lint/build/test if needed.")
|
||||
return 0
|
||||
|
||||
for command in commands:
|
||||
print(f"$ {command}")
|
||||
result = run_command(command, root)
|
||||
emit_stream("[stdout]", result.stdout, stream=sys.stdout)
|
||||
emit_stream("[stderr]", result.stderr, stream=sys.stderr)
|
||||
if result.returncode != 0:
|
||||
print(f"Validation failed: {command}", file=sys.stderr)
|
||||
return result.returncode
|
||||
|
||||
print("Validation succeeded.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user