remove prev files
This commit is contained in:
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "local-harness-engineering",
|
|
||||||
"interface": {
|
|
||||||
"displayName": "Local Harness Engineering"
|
|
||||||
},
|
|
||||||
"plugins": [
|
|
||||||
{
|
|
||||||
"name": "harness-engineering",
|
|
||||||
"source": {
|
|
||||||
"source": "local",
|
|
||||||
"path": "./plugins/harness-engineering"
|
|
||||||
},
|
|
||||||
"policy": {
|
|
||||||
"installation": "AVAILABLE",
|
|
||||||
"authentication": "ON_INSTALL"
|
|
||||||
},
|
|
||||||
"category": "Productivity"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
---
|
|
||||||
name: harness-review
|
|
||||||
description: Review a Harness Engineering repository against its persistent rules and design docs. Use when Codex is asked to review local changes, generated phase files, or implementation output against `AGENTS.md`, `docs/ARCHITECTURE.md`, `docs/ADR.md`, `docs/UI_GUIDE.md`, testing expectations, and Harness step acceptance criteria.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Harness Review
|
|
||||||
|
|
||||||
Use this skill when the user wants a repository-grounded review instead of generic commentary.
|
|
||||||
|
|
||||||
## Review input set
|
|
||||||
|
|
||||||
Read these first:
|
|
||||||
|
|
||||||
- `/AGENTS.md`
|
|
||||||
- `/docs/ARCHITECTURE.md`
|
|
||||||
- `/docs/ADR.md`
|
|
||||||
- `/docs/UI_GUIDE.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 architecture described in `docs/ARCHITECTURE.md`?
|
|
||||||
2. Does it stay within the technology choices documented in `docs/ADR.md`?
|
|
||||||
3. Are new or changed behaviors covered by tests or other explicit validation?
|
|
||||||
4. Does it violate any CRITICAL rule in `AGENTS.md`?
|
|
||||||
5. Do generated `phases/` files remain self-contained, executable, and internally consistent?
|
|
||||||
6. 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 risk or regression, 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 |
|
|
||||||
|------|------|------|
|
|
||||||
| Architecture compliance | PASS/FAIL | {details} |
|
|
||||||
| Tech stack compliance | PASS/FAIL | {details} |
|
|
||||||
| Test coverage | PASS/FAIL | {details} |
|
|
||||||
| CRITICAL rules | PASS/FAIL | {details} |
|
|
||||||
| Build and validation | PASS/FAIL | {details} |
|
|
||||||
|
|
||||||
## What not to do
|
|
||||||
|
|
||||||
- Do not approve changes just because they compile.
|
|
||||||
- Do not focus on style-only issues when correctness, architecture drift, or missing validation exists.
|
|
||||||
- Do not assume a passing hook means the implementation is acceptable; review the actual diff and docs.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
interface:
|
|
||||||
display_name: "Harness Review"
|
|
||||||
short_description: "Review changes against Harness project rules"
|
|
||||||
default_prompt: "Use Harness review to check architecture, tests, and rules."
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
---
|
|
||||||
name: harness-workflow
|
|
||||||
description: Plan and run the Harness Engineering workflow for this repository. Use when Codex needs to read `AGENTS.md` and `docs/*.md`, discuss implementation scope, draft phase plans, or create/update `phases/index.json`, `phases/{phase}/index.json`, and `phases/{phase}/stepN.md` files for staged execution.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Harness Workflow
|
|
||||||
|
|
||||||
Use this skill when the user is working in the 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`
|
|
||||||
|
|
||||||
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. Discuss before locking the plan
|
|
||||||
|
|
||||||
If scope, sequencing, or architecture choices are still ambiguous, surface the decision points before creating `phases/` files.
|
|
||||||
|
|
||||||
### 3. Design steps with strict boundaries
|
|
||||||
|
|
||||||
When drafting a phase plan:
|
|
||||||
|
|
||||||
1. Keep scope minimal. One step should usually touch one layer or one module.
|
|
||||||
2. Make each step self-contained. Every `stepN.md` must work in an isolated Codex session.
|
|
||||||
3. List prerequisite files explicitly. Never rely on "as discussed above".
|
|
||||||
4. Specify interfaces or invariants, not line-by-line implementations.
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Files to generate
|
|
||||||
|
|
||||||
### `phases/index.json`
|
|
||||||
|
|
||||||
Top-level phase registry. Append to `phases[]` when the file already exists.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"phases": [
|
|
||||||
{
|
|
||||||
"dir": "0-mvp",
|
|
||||||
"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": "<project-name>",
|
|
||||||
"phase": "<phase-name>",
|
|
||||||
"steps": [
|
|
||||||
{ "step": 0, "name": "project-setup", "status": "pending" },
|
|
||||||
{ "step": 1, "name": "core-types", "status": "pending" },
|
|
||||||
{ "step": 2, "name": "api-layer", "status": "pending" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `project`: from `AGENTS.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/ARCHITECTURE.md
|
|
||||||
- /docs/ADR.md
|
|
||||||
- {files from previous steps}
|
|
||||||
|
|
||||||
## Task
|
|
||||||
{specific instructions}
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
```bash
|
|
||||||
python scripts/validate_workspace.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
1. Run the acceptance commands.
|
|
||||||
2. Check AGENTS and docs for rule drift.
|
|
||||||
3. 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:
|
|
||||||
|
|
||||||
- `feat-{phase}` branch checkout/creation
|
|
||||||
- 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 code 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.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
interface:
|
|
||||||
display_name: "Harness Workflow"
|
|
||||||
short_description: "Guide Codex through Harness phase planning"
|
|
||||||
default_prompt: "Use the Harness workflow to plan phases and step files."
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
name = "harness_reviewer"
|
|
||||||
description = "Read-only reviewer for Harness projects, focused on architecture drift, critical rule violations, and missing validation."
|
|
||||||
model = "gpt-5.4"
|
|
||||||
model_reasoning_effort = "high"
|
|
||||||
sandbox_mode = "read-only"
|
|
||||||
developer_instructions = """
|
|
||||||
Review changes like a repository owner.
|
|
||||||
Prioritize correctness, architecture compliance, behavior regressions, and missing tests over style.
|
|
||||||
Always compare the patch against AGENTS.md, docs/ARCHITECTURE.md, docs/ADR.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.
|
|
||||||
"""
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
name = "phase_planner"
|
|
||||||
description = "Read-heavy Harness planner that decomposes docs into minimal, self-contained phase and step 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 phase boundaries, and draft self-contained steps.
|
|
||||||
Keep each step scoped to one layer or one module when possible.
|
|
||||||
Do not make code changes unless the parent agent explicitly asks you to write files.
|
|
||||||
Return concrete file paths, acceptance commands, and blocking assumptions.
|
|
||||||
"""
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# Project-scoped Codex defaults for the 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
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Run repository 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())
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# Project: PDFtoMD
|
|
||||||
|
|
||||||
## Repository Role
|
|
||||||
- 이 저장소는 PDF 문서를 AI Agent가 쉽게 탐색하고 읽을 수 있는 Obsidian 친화 Markdown 문서 묶음으로 변환하는 저장소입니다.
|
|
||||||
- 목표는 단순 텍스트 추출이 아니라, 읽기 순서, 문단 흐름, 수식, 표, 이미지, 캡션, 원본 위치 추적성을 보존한 구조화 변환입니다.
|
|
||||||
- 1차 목표는 Windows native 환경에서 완전 로컬로 실행되는 변환 엔진입니다.
|
|
||||||
- 2차 목표는 PyQt 기반 Windows UI와 선택적 외부 API 연동입니다.
|
|
||||||
- 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`.
|
|
||||||
|
|
||||||
## 기술 스택
|
|
||||||
- **Language**: Python 3.11+
|
|
||||||
- **Primary PDF Parser**: `Marker`
|
|
||||||
- **PDF Analysis / Splitting**: `PyMuPDF`
|
|
||||||
- **OCR / Layout Support**: `Marker`의 OCR/layout 기능을 우선 사용하고, 필요 시 `Surya` 계열 기능을 보조로 검토
|
|
||||||
- **Table Handling**: `Pandas`
|
|
||||||
- **Output Target**: Obsidian 친화 Markdown
|
|
||||||
- **Runtime**: Windows native, local-first, GPU 기본 사용, VRAM 8GB 기준으로 배치/청크 크기 제한
|
|
||||||
- **UI**: `PyQt`는 2차 목표
|
|
||||||
|
|
||||||
## 아키텍처 규칙
|
|
||||||
### Parser Engine Strategy
|
|
||||||
- `Marker`를 1차 기본 PDF parser로 사용한다.
|
|
||||||
- `Nougat`은 기본 엔진에서 제외하고, 필요 시 향후 수식 품질 비교 또는 fallback 후보로만 다룬다.
|
|
||||||
- 변환 파이프라인은 `Marker` 출력 형식에 직접 결합하지 않고, 프로젝트 내부 중간 표현(Document Model)을 거쳐 Markdown을 생성한다.
|
|
||||||
- 원본 `Marker` 결과, 변환 설정, 경고, 실패 정보는 진단 가능하도록 metadata/log 산출물로 보존한다.
|
|
||||||
|
|
||||||
### Reading Order & Paragraph Flow Strategy
|
|
||||||
PDF는 텍스트를 좌표 기반으로 저장하므로, 사람이 읽는 논리적인 순서로 재구성하는 것이 핵심이다.
|
|
||||||
- **Logical Reading Order**: 다단(Multi-column) 문서나 삽입 문구가 있는 레이아웃에서 텍스트 흐름을 추적하여 Markdown의 선형 구조로 배치한다.
|
|
||||||
- **Paragraph Stitching**: PDF 추출 시 발생하는 행 단위 분절을 제거하고, 문맥과 블록 정보를 이용해 완성된 문단으로 병합한다.
|
|
||||||
- **Semantic Mapping**: 단순 텍스트가 아닌 제목(Header), 본문(Body), 인용구(Blockquote), 리스트(List), 표(Table), 그림(Figure), 수식(Equation) 등의 의미 역할을 부여한다.
|
|
||||||
|
|
||||||
### Scientific, Mathematical, Table, Figure Strategy
|
|
||||||
- 수식은 Obsidian에서 렌더링 가능한 LaTeX Markdown을 우선한다.
|
|
||||||
- 인라인 수식은 `$ ... $`, 블록 수식은 `$$ ... $$` 형식을 사용한다.
|
|
||||||
- 수식 번호와 본문 내 참조 관계는 가능한 한 보존한다.
|
|
||||||
- LaTeX는 최소한 delimiter 짝, `\begin{...}` / `\end{...}` 짝, 흔한 깨짐 패턴을 검증한다.
|
|
||||||
- 단순 표는 Markdown table을 우선하고, 병합 셀/복잡한 표/수식 포함 표는 HTML table 또는 별도 CSV와 Markdown 링크를 허용한다.
|
|
||||||
- 이미지는 추출 파일, 캡션, figure 번호, 원본 페이지 정보를 함께 연결한다.
|
|
||||||
|
|
||||||
### Obsidian Output Strategy
|
|
||||||
- 출력 Markdown은 Obsidian vault에서 바로 열고 탐색할 수 있어야 한다.
|
|
||||||
- 문서 간 연결은 Obsidian 내부 링크와 표준 Markdown 호환성을 함께 고려한다.
|
|
||||||
- 이미지 링크, heading anchor, index 링크, 수식 block, 긴 표 링크 규칙은 프로젝트 표준으로 고정한다.
|
|
||||||
- Windows와 Obsidian에서 안전한 slug 기반 파일명을 사용하고, 원본 파일명은 metadata에 보존한다.
|
|
||||||
|
|
||||||
### Source Provenance & Index Strategy
|
|
||||||
- 변환된 주요 블록은 가능한 한 원본 PDF의 page, page range, block id, bbox, 변환된 md 파일, heading, line 위치와 연결한다.
|
|
||||||
- index 파일은 단순 목차가 아니라 AI Agent가 필요한 내용을 찾기 위한 탐색 지도 역할을 해야 한다.
|
|
||||||
- 긴 PDF는 기본적으로 20페이지 단위 chunk로 나누고, chunk별 변환 결과와 상태를 보존한다.
|
|
||||||
- 실패한 chunk만 재시도할 수 있도록 캐시와 상태 파일을 설계한다.
|
|
||||||
|
|
||||||
## 개발 프로세스
|
|
||||||
- CRITICAL: 새 기능 구현 시 반드시 테스트를 먼저 작성하고, 테스트가 통과하는 구현을 작성할 것 (TDD).
|
|
||||||
- 1차 구현은 CLI/라이브러리 변환 엔진을 먼저 안정화하고, PyQt UI는 2차 목표로 분리한다.
|
|
||||||
- 변환 품질 테스트는 전체 Markdown snapshot 비교보다 heading, 수식, 이미지, 표, index 링크, 예외 여부 등 부분 검증을 우선한다.
|
|
||||||
- `samples/` 폴더의 PDF는 회귀 테스트와 품질 평가용 corpus로 사용한다.
|
|
||||||
- 커밋 메시지는 conventional commits 형식을 따를 것 (`feat:`, `fix:`, `docs:`, `refactor:`).
|
|
||||||
- `scripts/execute.py`는 step 완료 후 코드/메타데이터 커밋을 정리하므로, step 프롬프트 안에서 별도 커밋을 만들 필요는 없음.
|
|
||||||
|
|
||||||
## Harness Workflow
|
|
||||||
- 먼저 `docs/PRD.md`, `docs/ARCHITECTURE.md`, `docs/ADR.md` 를 읽고 기획/설계 의도를 파악할 것.
|
|
||||||
- 단계별 실행 계획이 필요하면 repo skill `harness-workflow`를 사용해 `phases/` 아래 파일을 설계할 것.
|
|
||||||
- 변경사항 리뷰가 필요하면 repo skill `harness-review` 또는 Codex의 `/review`를 사용할 것.
|
|
||||||
- `phases/{phase}/index.json`은 phase 진행 상태의 단일 진실 공급원으로 취급할 것.
|
|
||||||
- 각 `stepN.md`는 독립된 Codex 세션에서도 실행 가능하도록 자기완결적으로 작성할 것.
|
|
||||||
|
|
||||||
## 검증
|
|
||||||
- 기본 검증 스크립트는 `python scripts/validate_workspace.py`.
|
|
||||||
- Node 프로젝트면 `package.json`의 `lint`, `build`, `test` 스크립트를 자동 탐지해 순서대로 실행.
|
|
||||||
- 다른 스택이면 `HARNESS_VALIDATION_COMMANDS` 환경 변수에 줄바꿈 기준으로 검증 커맨드를 지정.
|
|
||||||
|
|
||||||
## 명령어
|
|
||||||
- `python scripts/execute.py <phase-dir>`: Codex 기반 phase 순차 실행
|
|
||||||
- `python scripts/execute.py <phase-dir> --push`: phase 완료 후 브랜치 push
|
|
||||||
- `python scripts/validate_workspace.py`: 저장소 검증
|
|
||||||
-95
@@ -1,95 +0,0 @@
|
|||||||
# Architecture Decision Records
|
|
||||||
|
|
||||||
## 철학
|
|
||||||
- 공학적인 PDF 문서를 AI가 쉽게 이해할 수 있도록 수식, 이미지, 표, 글의 구조를 정확하게 변환한다.
|
|
||||||
- AI가 쉽게 분할된 문서에 접근할 수 있도록 챕터나 문단별로 파일명, line 위치, 원본 page 정보를 연결하는 index 파일을 생성한다.
|
|
||||||
- 로컬 실행과 재현 가능한 변환 결과를 1차 목표로 삼고, 외부 API와 UI는 이후 단계로 분리한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADR-001: Marker를 1차 PDF Parser로 채택
|
|
||||||
**결정**: `Nougat` 대신 `Marker`를 1차 기본 PDF parser로 사용한다.
|
|
||||||
|
|
||||||
**이유**:
|
|
||||||
- 1차 MVP 범위인 텍스트, 수식, 이미지, 표 구조화, Markdown 변환에 더 넓게 대응하기 위해서이다.
|
|
||||||
- Marker는 PDF를 Markdown/JSON 계열 구조로 변환하는 흐름에 적합하며, 후처리 파이프라인과 연결하기 좋다.
|
|
||||||
- Nougat은 수식 중심 과학 문서에 장점이 있지만, 현재 목표에서는 전체 문서 구조와 표/이미지 처리가 함께 중요하다.
|
|
||||||
|
|
||||||
**트레이드오프**:
|
|
||||||
- Nougat 기반 수식 변환 품질을 기본값으로 활용하지 않는다.
|
|
||||||
- Marker 결과 품질이 부족한 문서 유형에 대비해 향후 fallback adapter를 열어두어야 한다.
|
|
||||||
- Marker와 관련 모델의 라이선스와 Windows/GPU 실행 제약은 별도 검증이 필요하다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADR-002: 1차 목표는 Windows Native Local Execution
|
|
||||||
**결정**: 1차 MVP는 Windows native 환경에서 완전 로컬로 실행한다.
|
|
||||||
|
|
||||||
**이유**:
|
|
||||||
- 현재 목표 사용 환경이 Windows이며, 개인 프로젝트로 로컬 문서 처리를 우선하기 때문이다.
|
|
||||||
- 외부 API 의존을 제거하면 개인정보, 비용, 네트워크 실패 문제를 줄일 수 있다.
|
|
||||||
- VRAM 8GB 환경을 기준으로 chunk 처리와 배치 크기 제어를 설계할 수 있다.
|
|
||||||
|
|
||||||
**트레이드오프**:
|
|
||||||
- 외부 API 기반 LaTeX 복구, 이미지 설명, 품질 검토는 1차 범위에서 제외한다.
|
|
||||||
- Windows native에서 일부 ML 의존성 설치가 까다로울 수 있다.
|
|
||||||
- GPU OOM과 CPU fallback 정책을 반드시 고려해야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADR-003: Obsidian 친화 Markdown을 출력 표준으로 채택
|
|
||||||
**결정**: 변환 결과는 Obsidian에서 바로 탐색 가능한 Markdown 문서 묶음으로 출력한다.
|
|
||||||
|
|
||||||
**이유**:
|
|
||||||
- 사용자가 변환 결과를 개인 지식 관리와 AI Agent 탐색에 활용하려는 목적에 맞다.
|
|
||||||
- 내부 링크, heading 기반 이동, 이미지 embed, 수식 렌더링을 활용할 수 있다.
|
|
||||||
- 긴 PDF를 여러 Markdown 파일과 index로 나누는 구조와 잘 맞는다.
|
|
||||||
|
|
||||||
**트레이드오프**:
|
|
||||||
- 순수 GitHub Markdown만 목표로 할 때보다 링크/이미지/수식 규칙을 더 신중하게 정해야 한다.
|
|
||||||
- Obsidian 내부 링크와 표준 Markdown 링크 사이의 호환성 정책이 필요하다.
|
|
||||||
- 파일명 slug, heading anchor, 이미지 경로 규칙을 프로젝트 표준으로 고정해야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADR-004: 내부 Document Model과 Source Provenance를 사용
|
|
||||||
**결정**: Marker 출력물을 바로 최종 Markdown으로 저장하지 않고 내부 Document Model을 거쳐 렌더링한다.
|
|
||||||
|
|
||||||
**이유**:
|
|
||||||
- parser engine 교체 가능성을 유지하기 위해서이다.
|
|
||||||
- Markdown 출력, index 생성, 품질 검증, 재시도, 로그 진단을 동일한 구조 위에서 처리할 수 있다.
|
|
||||||
- AI Agent가 변환된 문장을 원본 PDF page, block id, bbox, line 위치와 연결할 수 있어야 한다.
|
|
||||||
|
|
||||||
**트레이드오프**:
|
|
||||||
- 초기 구현량이 늘어난다.
|
|
||||||
- Document Model schema를 먼저 정의해야 한다.
|
|
||||||
- Marker 원본 출력과 내부 모델 사이의 매핑 오류를 테스트해야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADR-005: CLI/Library First, PyQt UI Second
|
|
||||||
**결정**: 1차는 CLI와 라이브러리 변환 엔진을 먼저 만들고, PyQt UI는 2차 목표로 둔다.
|
|
||||||
|
|
||||||
**이유**:
|
|
||||||
- 변환 품질과 테스트 자동화가 UI보다 먼저 안정화되어야 한다.
|
|
||||||
- CLI/라이브러리 계층이 안정적이면 PyQt UI는 얇은 호출 계층으로 만들 수 있다.
|
|
||||||
- Harness/TDD 기반 검증 흐름과 잘 맞는다.
|
|
||||||
|
|
||||||
**트레이드오프**:
|
|
||||||
- 초기 사용자 경험은 UI보다 제한적이다.
|
|
||||||
- UI 관련 상태 관리와 진행률 표시 설계는 후속 단계에서 다룬다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADR-006: Chunked and Resumable Conversion
|
|
||||||
**결정**: 긴 PDF는 기본적으로 20페이지 단위 chunk로 나누고, chunk별 상태와 캐시를 보존한다.
|
|
||||||
|
|
||||||
**이유**:
|
|
||||||
- AI Agent가 긴 문서를 한 번에 읽기 어렵고, 변환도 오래 걸리기 때문이다.
|
|
||||||
- GPU VRAM 8GB 환경에서 큰 문서를 안정적으로 처리하려면 chunk 단위 작업이 필요하다.
|
|
||||||
- 실패한 chunk만 재시도할 수 있어야 실사용성이 높다.
|
|
||||||
|
|
||||||
**트레이드오프**:
|
|
||||||
- chunk 경계에서 문단, 표, 그림, 수식이 걸칠 수 있다.
|
|
||||||
- chunk 결과를 합쳐 index를 만들 때 page range와 heading 연결을 신중히 처리해야 한다.
|
|
||||||
- 캐시 무효화 정책이 필요하다.
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
# 아키텍처
|
|
||||||
|
|
||||||
## 기본 원칙
|
|
||||||
- 1차 목표는 UI가 아니라 안정적인 CLI/라이브러리 변환 엔진이다.
|
|
||||||
- PDF parser는 `Marker`를 기본 엔진으로 사용한다.
|
|
||||||
- 변환 파이프라인은 Marker 출력에 직접 결합하지 않고 내부 중간 표현(Document Model)을 거친다.
|
|
||||||
- 출력은 Obsidian 친화 Markdown을 우선하되, 표준 Markdown과의 호환성을 가능한 한 유지한다.
|
|
||||||
- 긴 문서는 20페이지 단위 chunk로 처리하고, chunk별 상태/캐시/로그를 남겨 재시도가 가능해야 한다.
|
|
||||||
|
|
||||||
## 목표 디렉토리 구조
|
|
||||||
```
|
|
||||||
venv/ # 가상 환경
|
|
||||||
samples/ # 품질 검증용 샘플 PDF
|
|
||||||
src/
|
|
||||||
├── converter/ # PDF to Markdown 변환 핵심
|
|
||||||
│ ├── engines/ # Marker 및 향후 parser adapter
|
|
||||||
│ ├── model/ # 내부 Document Model
|
|
||||||
│ ├── renderers/ # Obsidian Markdown 출력
|
|
||||||
│ ├── indexer/ # index 파일 생성
|
|
||||||
│ └── diagnostics/ # 로그, 경고, 변환 상태
|
|
||||||
├── ui/ # 2차 목표: PyQt UI 컴포넌트
|
|
||||||
└── utils/ # 유틸리티 + 헬퍼
|
|
||||||
```
|
|
||||||
|
|
||||||
## 주요 컴포넌트
|
|
||||||
### Converter Core
|
|
||||||
- PDF 입력, 사전 분석, chunk 분할, parser 실행, 중간 표현 생성, 출력 렌더링을 조율한다.
|
|
||||||
- UI와 분리되어 CLI와 테스트에서 직접 호출 가능해야 한다.
|
|
||||||
|
|
||||||
### Parser Engine Adapter
|
|
||||||
- 1차 구현은 `MarkerEngine`만 대상으로 한다.
|
|
||||||
- 향후 `Nougat`, `Docling`, `PyMuPDF` 등 다른 엔진을 붙일 수 있도록 adapter 경계를 둔다.
|
|
||||||
- parser adapter는 원본 parser 결과와 프로젝트 내부 Document Model 사이의 변환을 담당한다.
|
|
||||||
|
|
||||||
### Document Model
|
|
||||||
내부 중간 표현은 최소한 다음 정보를 담아야 한다.
|
|
||||||
- document id, title, source path, output slug
|
|
||||||
- chunk id, page range, conversion status
|
|
||||||
- block id, block type, text/content
|
|
||||||
- heading level, paragraph, list, quote, equation, table, figure
|
|
||||||
- source page, bbox, confidence, parser metadata
|
|
||||||
- image/table asset path
|
|
||||||
- warnings/errors
|
|
||||||
|
|
||||||
### Obsidian Renderer
|
|
||||||
- Document Model을 Obsidian 친화 Markdown 파일로 렌더링한다.
|
|
||||||
- 수식은 `$ ... $`, `$$ ... $$` 형식을 지킨다.
|
|
||||||
- 이미지는 Obsidian 내부 링크 또는 프로젝트에서 정한 표준 이미지 링크 형식으로 연결한다.
|
|
||||||
- 복잡한 표는 Markdown table에 억지로 맞추지 않고 HTML table 또는 별도 CSV 링크를 허용한다.
|
|
||||||
|
|
||||||
### Index Builder
|
|
||||||
- 단순 목차가 아니라 AI Agent가 문서 내부로 진입하기 위한 탐색 index를 만든다.
|
|
||||||
- heading, 주요 문단, 표, 그림, 수식에 대해 Markdown 파일, heading, line 위치, 원본 page 정보를 연결한다.
|
|
||||||
- 가능한 경우 block id와 bbox를 metadata에 보존한다.
|
|
||||||
|
|
||||||
### Diagnostics & Recovery
|
|
||||||
- 변환 설정, Marker 버전, GPU/CPU 사용 여부, 처리 시간, 경고, 실패 chunk를 기록한다.
|
|
||||||
- 완료된 chunk는 재사용하고 실패한 chunk만 재시도할 수 있어야 한다.
|
|
||||||
- 원본 Marker 결과와 내부 Document Model snapshot은 디버깅 가능한 형태로 보존한다.
|
|
||||||
|
|
||||||
## 프로그램 흐름
|
|
||||||
```
|
|
||||||
PDF 파일 입력
|
|
||||||
-> 사전 분석: 페이지 수, 텍스트 포함 여부, 스캔 여부, 예상 chunk 수 확인
|
|
||||||
-> 20페이지 단위 chunk 분할
|
|
||||||
-> chunk별 Marker 변환
|
|
||||||
-> Marker 결과를 내부 Document Model로 변환
|
|
||||||
-> reading order, paragraph stitching, semantic mapping 보정
|
|
||||||
-> 이미지/표/수식 asset 정리
|
|
||||||
-> Obsidian 친화 Markdown 렌더링
|
|
||||||
-> index 파일 생성
|
|
||||||
-> metadata/log 저장
|
|
||||||
```
|
|
||||||
|
|
||||||
## 상태 관리
|
|
||||||
- 문서 단위 상태와 chunk 단위 상태를 분리한다.
|
|
||||||
- chunk 상태는 `pending`, `running`, `completed`, `error`, `skipped` 같은 값으로 관리한다.
|
|
||||||
- 변환 중 실패가 발생해도 완료된 chunk 결과는 유지한다.
|
|
||||||
- 최종 index는 chunk 결과를 기준으로 재생성 가능해야 한다.
|
|
||||||
|
|
||||||
## 변환 결과 출력 구조
|
|
||||||
```
|
|
||||||
output/
|
|
||||||
└── document-slug/
|
|
||||||
├── document-slug_index.md
|
|
||||||
├── document-slug_001.md
|
|
||||||
├── document-slug_002.md
|
|
||||||
├── document-slug_003.md
|
|
||||||
├── images/
|
|
||||||
│ ├── page-001_fig-001.png
|
|
||||||
│ └── page-012_fig-003.png
|
|
||||||
├── tables/
|
|
||||||
│ ├── page-005_table-001.csv
|
|
||||||
│ └── page-008_table-002.html
|
|
||||||
└── metadata/
|
|
||||||
├── document.json
|
|
||||||
├── chunks.json
|
|
||||||
├── parser-output/
|
|
||||||
└── conversion.log
|
|
||||||
```
|
|
||||||
|
|
||||||
## Obsidian 출력 규칙
|
|
||||||
- 파일명은 Windows와 Obsidian에서 안전한 slug 기반 이름을 사용한다.
|
|
||||||
- 원본 PDF 파일명은 metadata에 보존한다.
|
|
||||||
- heading은 Obsidian 내부 링크 대상이 될 수 있도록 안정적으로 생성한다.
|
|
||||||
- 이미지는 상대 경로 또는 Obsidian 내부 링크 중 프로젝트 표준을 정해 일관되게 사용한다.
|
|
||||||
- index 파일은 각 chunk 파일과 주요 heading으로 이동할 수 있어야 한다.
|
|
||||||
- 수식은 Obsidian에서 렌더링 가능한 Markdown math 문법을 사용한다.
|
|
||||||
|
|
||||||
## 검증 전략
|
|
||||||
- TDD를 기본으로 한다.
|
|
||||||
- 전체 Markdown snapshot 비교보다 부분 품질 검증을 우선한다.
|
|
||||||
- 샘플 PDF별로 heading 추출, 수식 block 수, 이미지 파일 생성, 표 구조 보존, index 링크 생성, 예외 발생 여부를 확인한다.
|
|
||||||
- Windows native, 한글 경로, 긴 파일명, 8GB VRAM 제한을 검증 항목에 포함한다.
|
|
||||||
-56
@@ -1,56 +0,0 @@
|
|||||||
# PRD: PDFtoMD
|
|
||||||
|
|
||||||
## 목표
|
|
||||||
PDFtoMD는 수학, 공학, 역학 중심의 PDF 문서를 AI Agent가 쉽게 접근하고 읽을 수 있는 Obsidian 친화 Markdown 문서 묶음으로 변환하는 프로그램이다.
|
|
||||||
|
|
||||||
이 프로젝트의 목표는 PDF의 텍스트를 단순 추출하는 것이 아니라, 원문 문서의 논리 구조와 출처 추적성을 보존하면서 AI가 탐색 가능한 지식 자료로 재구성하는 것이다.
|
|
||||||
|
|
||||||
## 사용자
|
|
||||||
- PDF 문서를 Markdown으로 변환해 AI Agent, RAG, 개인 지식 관리 도구에 활용하고 싶은 사용자
|
|
||||||
- 수식, 표, 이미지가 많은 논문/공학 문서를 Obsidian에서 읽고 관리하고 싶은 사용자
|
|
||||||
- 긴 PDF를 여러 Markdown 파일과 index로 나누어 부분 탐색하고 싶은 사용자
|
|
||||||
|
|
||||||
## 1차 MVP 범위
|
|
||||||
- Windows native 환경에서 완전 로컬 실행
|
|
||||||
- GPU 기본 사용, VRAM 8GB 환경을 기준으로 안정적인 chunk 처리
|
|
||||||
- PDF parser는 `Marker`를 기본 엔진으로 사용
|
|
||||||
- PDF 텍스트를 Markdown 문단과 heading 구조로 변환
|
|
||||||
- PDF 내 수식을 Obsidian 렌더링이 가능한 LaTeX Markdown으로 변환
|
|
||||||
- PDF 내 이미지를 추출하고 Markdown에서 연결
|
|
||||||
- 이미지의 figure 번호, 캡션, 원본 페이지 정보를 가능한 한 보존
|
|
||||||
- PDF 내 표를 구조화하고 Markdown table, HTML table, CSV 중 적절한 방식으로 출력
|
|
||||||
- 페이지 수가 많은 문서를 20페이지 단위 chunk로 분할 변환
|
|
||||||
- AI Agent가 쉽게 접근할 수 있도록 chunk 파일, heading, 문단, line 위치, 원본 page 정보를 연결하는 index 파일 생성
|
|
||||||
- 변환 설정, 경고, 실패 chunk, 처리 시간 등 진단 정보를 metadata/log로 보존
|
|
||||||
|
|
||||||
## 2차 범위
|
|
||||||
- PyQt 기반 Windows UI
|
|
||||||
- 외부 API를 선택적으로 활용하는 수식 복구, 이미지 설명, 품질 검토 기능
|
|
||||||
- Marker 외 parser engine fallback 또는 비교 실행
|
|
||||||
- 고급 Obsidian vault 연동
|
|
||||||
|
|
||||||
## 핵심 기능
|
|
||||||
1. PDF 문서를 Obsidian 친화 Markdown 문서 묶음으로 변환
|
|
||||||
2. 수식을 `$ ... $` 또는 `$$ ... $$` 형식의 LaTeX로 보존
|
|
||||||
3. 논문에서 자주 쓰이는 다중 컬럼 문서를 Markdown의 선형 구조로 재배치
|
|
||||||
4. 이미지 추출 및 Markdown 연결
|
|
||||||
5. 표 구조화 및 표 유형별 Markdown/HTML/CSV 출력
|
|
||||||
6. 긴 PDF를 여러 chunk Markdown 파일로 분할 변환
|
|
||||||
7. chunk/heading/문단별 접근을 돕는 index 파일 생성
|
|
||||||
8. 원본 PDF page, page range, block id, bbox 등 출처 추적 정보 보존
|
|
||||||
9. 중간 실패 후 실패 chunk만 재시도 가능한 변환 상태 관리
|
|
||||||
10. `samples/` PDF 기반 품질 검증과 회귀 테스트 지원
|
|
||||||
|
|
||||||
## 품질 기준
|
|
||||||
- 원문 읽기 순서가 Markdown에서 자연스럽게 유지되어야 한다.
|
|
||||||
- heading, 본문, 리스트, 인용, 표, 그림, 수식의 의미 역할이 구분되어야 한다.
|
|
||||||
- 수식 delimiter와 기본 LaTeX 구조가 깨지지 않아야 한다.
|
|
||||||
- 이미지와 캡션, figure 번호, 본문 참조가 가능한 한 연결되어야 한다.
|
|
||||||
- 표는 구조 손실을 최소화하는 형식으로 저장되어야 한다.
|
|
||||||
- index 파일에서 원하는 섹션이나 문단의 Markdown 위치와 원본 PDF page를 찾을 수 있어야 한다.
|
|
||||||
- Windows 경로, 한글 파일명, 긴 문서, GPU 메모리 부족 상황을 고려해야 한다.
|
|
||||||
|
|
||||||
## UI
|
|
||||||
- UI는 2차 목표로 PyQt를 사용한다.
|
|
||||||
- UI는 변환 엔진을 직접 구현하지 않고 CLI/라이브러리 계층을 호출하는 thin client로 둔다.
|
|
||||||
- 미니멀하고 깔끔한 Windows 표준 디자인을 따른다.
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "harness-engineering",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Repo-local Harness Engineering slash commands for Codex.",
|
|
||||||
"interface": {
|
|
||||||
"displayName": "Harness Engineering",
|
|
||||||
"shortDescription": "Harness planning and review prompts for this repo",
|
|
||||||
"longDescription": "Optional local plugin that exposes Harness Engineering 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 Harness Engineering to plan a new phase for this repository.",
|
|
||||||
"Review my changes against the Harness docs and rules."
|
|
||||||
],
|
|
||||||
"brandColor": "#2563EB"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
interface:
|
|
||||||
display_name: "Harness Engineering"
|
|
||||||
short_description: "Use Harness slash commands in this repository"
|
|
||||||
default_prompt: "Use Harness Engineering to plan a phase or review changes in this repository."
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
---
|
|
||||||
description: Run the Harness Engineering planning workflow for this repository.
|
|
||||||
---
|
|
||||||
|
|
||||||
# /harness
|
|
||||||
|
|
||||||
## Preflight
|
|
||||||
|
|
||||||
- Read `/AGENTS.md`, `/docs/PRD.md`, `/docs/ARCHITECTURE.md`, `/docs/ADR.md`, and `/docs/UI_GUIDE.md` if they exist.
|
|
||||||
- Confirm whether the user wants discussion only, a draft plan, or file generation under `phases/`.
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 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, and acceptance commands line up.
|
|
||||||
- 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 Harness phase files
|
|
||||||
- **Status**: success | partial | failed
|
|
||||||
- **Details**: phase name, step count, and any blockers
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
- Suggest the next natural command, usually `python scripts/execute.py <phase>` or a focused edit to one generated step.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
---
|
|
||||||
description: Review local changes against Harness repository rules and docs.
|
|
||||||
---
|
|
||||||
|
|
||||||
# /review
|
|
||||||
|
|
||||||
## Preflight
|
|
||||||
|
|
||||||
- Read `/AGENTS.md`, `/docs/ARCHITECTURE.md`, `/docs/ADR.md`, and `/docs/UI_GUIDE.md` if they exist.
|
|
||||||
- Identify the changed 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: docs, changed files, generated phase files, and validation output if available.
|
|
||||||
- Prioritize correctness, architecture drift, CRITICAL rule violations, and missing tests over style commentary.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
- Invoke `$harness-review`.
|
|
||||||
- Use Codex built-in `/review` when the user specifically wants a code-review style pass over the working tree or git diff.
|
|
||||||
- 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 behavioral risk.
|
|
||||||
- If no findings remain, say so explicitly and mention residual risks or missing evidence.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
## Result
|
|
||||||
- **Action**: reviewed 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.
|
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,424 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Harness Step Executor - run 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
|
|
||||||
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} 프로젝트의 개발자입니다. 아래 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. 변경사항은 워킹 트리에 남겨라. 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" Harness Step Executor")
|
|
||||||
print(f" Phase: {self._phase_name} | Steps: {self._total}")
|
|
||||||
if self._auto_push:
|
|
||||||
print(f" Auto-push: enabled")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
|
|
||||||
def _check_blockers(self):
|
|
||||||
index = self._read_json(self._index_file)
|
|
||||||
for s in reversed(index["steps"]):
|
|
||||||
if s["status"] == "error":
|
|
||||||
print(f"\n ✗ Step {s['step']} ({s['name']}) failed.")
|
|
||||||
print(f" Error: {s.get('error_message', 'unknown')}")
|
|
||||||
print(f" Fix and reset status to 'pending' to retry.")
|
|
||||||
sys.exit(1)
|
|
||||||
if s["status"] == "blocked":
|
|
||||||
print(f"\n ⏸ Step {s['step']} ({s['name']}) blocked.")
|
|
||||||
print(f" Reason: {s.get('blocked_reason', 'unknown')}")
|
|
||||||
print(f" Resolve and reset status to 'pending' to retry.")
|
|
||||||
sys.exit(2)
|
|
||||||
if s["status"] != "pending":
|
|
||||||
break
|
|
||||||
|
|
||||||
def _ensure_created_at(self):
|
|
||||||
index = self._read_json(self._index_file)
|
|
||||||
if "created_at" not in index:
|
|
||||||
index["created_at"] = self._stamp()
|
|
||||||
self._write_json(self._index_file, index)
|
|
||||||
|
|
||||||
# --- 실행 루프 ---
|
|
||||||
|
|
||||||
def _execute_single_step(self, step: dict, guardrails: str) -> bool:
|
|
||||||
"""단일 step 실행 (재시도 포함). 완료되면 True, 실패/차단이면 False."""
|
|
||||||
step_num, step_name = step["step"], step["name"]
|
|
||||||
done = sum(1 for s in self._read_json(self._index_file)["steps"] if s["status"] == "completed")
|
|
||||||
prev_error = None
|
|
||||||
|
|
||||||
for attempt in range(1, self.MAX_RETRIES + 1):
|
|
||||||
index = self._read_json(self._index_file)
|
|
||||||
step_context = self._build_step_context(index)
|
|
||||||
preamble = self._build_preamble(guardrails, step_context, prev_error)
|
|
||||||
|
|
||||||
tag = f"Step {step_num}/{self._total - 1} ({done} done): {step_name}"
|
|
||||||
if attempt > 1:
|
|
||||||
tag += f" [retry {attempt}/{self.MAX_RETRIES}]"
|
|
||||||
|
|
||||||
with progress_indicator(tag) as pi:
|
|
||||||
self._invoke_codex(step, preamble)
|
|
||||||
elapsed = int(pi.elapsed)
|
|
||||||
|
|
||||||
index = self._read_json(self._index_file)
|
|
||||||
status = next((s.get("status", "pending") for s in index["steps"] if s["step"] == step_num), "pending")
|
|
||||||
ts = self._stamp()
|
|
||||||
|
|
||||||
if status == "completed":
|
|
||||||
for s in index["steps"]:
|
|
||||||
if s["step"] == step_num:
|
|
||||||
s["completed_at"] = ts
|
|
||||||
self._write_json(self._index_file, index)
|
|
||||||
self._commit_step(step_num, step_name)
|
|
||||||
print(f" ✓ Step {step_num}: {step_name} [{elapsed}s]")
|
|
||||||
return True
|
|
||||||
|
|
||||||
if status == "blocked":
|
|
||||||
for s in index["steps"]:
|
|
||||||
if s["step"] == step_num:
|
|
||||||
s["blocked_at"] = ts
|
|
||||||
self._write_json(self._index_file, index)
|
|
||||||
reason = next((s.get("blocked_reason", "") for s in index["steps"] if s["step"] == step_num), "")
|
|
||||||
print(f" ⏸ Step {step_num}: {step_name} blocked [{elapsed}s]")
|
|
||||||
print(f" Reason: {reason}")
|
|
||||||
self._update_top_index("blocked")
|
|
||||||
sys.exit(2)
|
|
||||||
|
|
||||||
err_msg = next(
|
|
||||||
(s.get("error_message", "Step did not update status") for s in index["steps"] if s["step"] == step_num),
|
|
||||||
"Step did not update status",
|
|
||||||
)
|
|
||||||
|
|
||||||
if attempt < self.MAX_RETRIES:
|
|
||||||
for s in index["steps"]:
|
|
||||||
if s["step"] == step_num:
|
|
||||||
s["status"] = "pending"
|
|
||||||
s.pop("error_message", None)
|
|
||||||
self._write_json(self._index_file, index)
|
|
||||||
prev_error = err_msg
|
|
||||||
print(f" ↻ Step {step_num}: retry {attempt}/{self.MAX_RETRIES} — {err_msg}")
|
|
||||||
else:
|
|
||||||
for s in index["steps"]:
|
|
||||||
if s["step"] == step_num:
|
|
||||||
s["status"] = "error"
|
|
||||||
s["error_message"] = f"[{self.MAX_RETRIES}회 시도 후 실패] {err_msg}"
|
|
||||||
s["failed_at"] = ts
|
|
||||||
self._write_json(self._index_file, index)
|
|
||||||
self._commit_step(step_num, step_name)
|
|
||||||
print(f" ✗ Step {step_num}: {step_name} failed after {self.MAX_RETRIES} attempts [{elapsed}s]")
|
|
||||||
print(f" Error: {err_msg}")
|
|
||||||
self._update_top_index("error")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
return False # unreachable
|
|
||||||
|
|
||||||
def _execute_all_steps(self, guardrails: str):
|
|
||||||
while True:
|
|
||||||
index = self._read_json(self._index_file)
|
|
||||||
pending = next((s for s in index["steps"] if s["status"] == "pending"), None)
|
|
||||||
if pending is None:
|
|
||||||
print("\n All steps completed!")
|
|
||||||
return
|
|
||||||
|
|
||||||
step_num = pending["step"]
|
|
||||||
for s in index["steps"]:
|
|
||||||
if s["step"] == step_num and "started_at" not in s:
|
|
||||||
s["started_at"] = self._stamp()
|
|
||||||
self._write_json(self._index_file, index)
|
|
||||||
break
|
|
||||||
|
|
||||||
self._execute_single_step(pending, guardrails)
|
|
||||||
|
|
||||||
def _finalize(self):
|
|
||||||
index = self._read_json(self._index_file)
|
|
||||||
index["completed_at"] = self._stamp()
|
|
||||||
self._write_json(self._index_file, index)
|
|
||||||
self._update_top_index("completed")
|
|
||||||
|
|
||||||
self._run_git("add", "-A")
|
|
||||||
if self._run_git("diff", "--cached", "--quiet").returncode != 0:
|
|
||||||
msg = f"chore({self._phase_name}): mark phase completed"
|
|
||||||
r = self._run_git("commit", "-m", msg)
|
|
||||||
if r.returncode == 0:
|
|
||||||
print(f" ✓ {msg}")
|
|
||||||
|
|
||||||
if self._auto_push:
|
|
||||||
branch = f"feat-{self._phase_name}"
|
|
||||||
r = self._run_git("push", "-u", "origin", branch)
|
|
||||||
if r.returncode != 0:
|
|
||||||
print(f"\n ERROR: git push 실패: {r.stderr.strip()}")
|
|
||||||
sys.exit(1)
|
|
||||||
print(f" ✓ Pushed to origin/{branch}")
|
|
||||||
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print(f" Phase '{self._phase_name}' completed!")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description="Harness Step Executor")
|
|
||||||
parser.add_argument("phase_dir", help="Phase directory name (e.g. 0-mvp)")
|
|
||||||
parser.add_argument("--push", action="store_true", help="Push branch after completion")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
StepExecutor(args.phase_dir, auto_push=args.push).run()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,562 +0,0 @@
|
|||||||
"""execute.py Codex migration 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개를 가진 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))
|
|
||||||
(d / "step2.md").write_text("# Step 2: UI\n\nUI를 구현하세요.")
|
|
||||||
|
|
||||||
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 (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())
|
|
||||||
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())
|
|
||||||
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())
|
|
||||||
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_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="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]
|
|
||||||
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 "UI를 구현하세요" 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
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Run repository validation commands for the Harness template."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_NPM_ORDER = ("lint", "build", "test")
|
|
||||||
|
|
||||||
|
|
||||||
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 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
|
|
||||||
commands = discover_commands(root)
|
|
||||||
|
|
||||||
if not commands:
|
|
||||||
print("No validation commands configured.")
|
|
||||||
print("Set HARNESS_VALIDATION_COMMANDS or add npm scripts for lint/build/test.")
|
|
||||||
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