593 lines
25 KiB
Python
593 lines
25 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Harness Step Executor — phase 내 step을 순차 실행하고 자가 교정한다.
|
|
|
|
Usage:
|
|
python scripts/execute.py <phase-dir> [--push | --dry-run]
|
|
"""
|
|
|
|
import argparse
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import types
|
|
from datetime import datetime, timezone, timedelta
|
|
from pathlib import Path, PureWindowsPath
|
|
from typing import Optional
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
class CodexEnvironmentError(RuntimeError):
|
|
"""재시도로 해결할 수 없는 Codex CLI 환경 오류."""
|
|
|
|
|
|
class ContextError(ValueError):
|
|
"""Step 실행 전에 수정해야 하는 문서/설정 오류."""
|
|
|
|
|
|
@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):
|
|
if (not phase_dir_name or phase_dir_name in {".", ".."}
|
|
or any(c in phase_dir_name for c in "/\\:")):
|
|
raise ContextError("phase must be a directory name under phases/")
|
|
self._root = str(ROOT)
|
|
self._phases_dir = ROOT / "phases"
|
|
self._phase_dir = self._phases_dir / phase_dir_name
|
|
if self._phase_dir.resolve().parent != self._phases_dir.resolve():
|
|
raise ContextError("phase resolves outside phases/")
|
|
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, *, dry_run: bool = False):
|
|
if dry_run and self._auto_push:
|
|
raise ContextError("--dry-run and --push cannot be combined")
|
|
self._print_header()
|
|
self._check_blockers()
|
|
pending = [s for s in self._read_json(self._index_file)["steps"]
|
|
if s["status"] == "pending"]
|
|
if dry_run:
|
|
if not pending:
|
|
print("No pending steps.")
|
|
return
|
|
sections = self._prompt_sections(pending[0])
|
|
for label, body in sections:
|
|
print(f" {label}: {len(body)} characters")
|
|
print(f" Total prompt: {len(self._render_prompt(sections))} characters")
|
|
return
|
|
for step in pending:
|
|
self._build_prompt(step)
|
|
self._checkout_branch()
|
|
self._ensure_created_at()
|
|
self._execute_all_steps()
|
|
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, encoding="utf-8", errors="replace")
|
|
|
|
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 _document_path(self, raw: str) -> Path:
|
|
if (not isinstance(raw, str) or not raw.strip()
|
|
or PureWindowsPath(raw).drive or PureWindowsPath(raw).root
|
|
or any(c in raw for c in "*?[]:")):
|
|
raise ContextError(f"context file must be a repository-relative file: {raw!r}")
|
|
root = Path(self._root).resolve()
|
|
path = (root / raw.replace("\\", "/")).resolve()
|
|
if not path.is_relative_to(root):
|
|
raise ContextError(f"context file resolves outside repository: {raw}")
|
|
return path
|
|
|
|
def _read_document(self, path: Path) -> tuple[str, str]:
|
|
label = path.relative_to(Path(self._root).resolve()).as_posix()
|
|
try:
|
|
return label, path.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeError) as exc:
|
|
raise ContextError(f"cannot read UTF-8 document {label}: {exc}") from exc
|
|
|
|
def _prompt_sections(self, step: dict, prev_error: Optional[str] = None) -> list:
|
|
self._validation_mode(step)
|
|
paths = step.get("context_files", [])
|
|
if not isinstance(paths, list):
|
|
raise ContextError("context_files must be a string array")
|
|
step_path = self._document_path(f"phases/{self._phase_dir_name}/step{step['step']}.md")
|
|
agents = self._document_path("AGENTS.md")
|
|
handoff = self._document_path(f"docs/phase/{self._phase_dir_name}/handoff.md")
|
|
seen = {step_path, agents, handoff}
|
|
sections = [self._read_document(agents)] if agents.exists() else []
|
|
sections.append(("작업 규칙", self._work_rules(step)))
|
|
for raw in paths:
|
|
path = self._document_path(raw)
|
|
if not path.is_file():
|
|
raise ContextError(f"context file not found: {raw}")
|
|
if path not in seen:
|
|
sections.append(self._read_document(path))
|
|
seen.add(path)
|
|
if handoff.exists():
|
|
sections.append(self._read_document(handoff))
|
|
else:
|
|
summary = self._build_step_context(self._read_json(self._index_file), step["step"])
|
|
if summary:
|
|
sections.append((f"이전 step 요약 (phases/{self._phase_dir_name}/index.json)", summary))
|
|
if prev_error:
|
|
sections.append(("이전 시도 실패", self._bounded_error(prev_error)))
|
|
sections.append(("직전 원본 로그",
|
|
f"phases/{self._phase_dir_name}/step{step['step']}-output.json"))
|
|
sections.append(self._read_document(step_path))
|
|
return sections
|
|
|
|
@staticmethod
|
|
def _render_prompt(sections: list) -> str:
|
|
return "\n\n---\n\n".join(f"## {label}\n\n{body}" for label, body in sections)
|
|
|
|
def _build_prompt(self, step: dict, prev_error: Optional[str] = None) -> str:
|
|
return self._render_prompt(self._prompt_sections(step, prev_error))
|
|
|
|
@staticmethod
|
|
def _validation_mode(step: dict) -> str:
|
|
mode = step.get("validation", "full")
|
|
if mode not in ("full", "docs-only"):
|
|
raise ContextError("validation must be full or docs-only")
|
|
return mode
|
|
|
|
@staticmethod
|
|
def _bounded_error(error: str) -> str:
|
|
if len(error) <= 4000:
|
|
return error
|
|
marker = "\n[중간 생략; 원본 로그 참조]\n"
|
|
return error[:1500] + marker + error[-(2500 - len(marker)):]
|
|
|
|
@staticmethod
|
|
def _build_step_context(index: dict, step_num: int) -> str:
|
|
prior = [s for s in index["steps"]
|
|
if s["step"] < step_num and s["status"] == "completed"]
|
|
if not prior:
|
|
return ""
|
|
step = max(prior, key=lambda s: s["step"])
|
|
summary = step.get("summary", "")
|
|
if not summary:
|
|
return ""
|
|
if len(summary) > 1000:
|
|
summary = summary[:980] + "\n[이후 생략; index 참조]"
|
|
return f"Step {step['step']} ({step['name']}): {summary}"
|
|
|
|
def _work_rules(self, step: dict) -> str:
|
|
notes = f"docs/phase/{self._phase_dir_name}"
|
|
return (
|
|
f"당신은 {self._project} 프로젝트의 개발자입니다. 아래 step을 수행하세요.\n\n"
|
|
f"1. 지정한 문서와 관련 코드를 읽고, 추가 자료는 필요할 때만 읽어라.\n"
|
|
f"2. 현재 step 범위만 수행하라. 아래 인계·step 기록은 허용된 산출물이다.\n"
|
|
f"3. 기존 테스트를 깨뜨리지 마라.\n"
|
|
f"4. AC(Acceptance Criteria) 검증을 직접 실행하라.\n"
|
|
f"5. 성공·실패·차단을 보고하기 전에 {notes}/handoff.md와 {notes}/step{step['step']}.md를 갱신하라.\n"
|
|
f" handoff는 현재 상황, 유효한 결정과 이유, 미해결 문제, 검증 명령·결과, 다음 시작점과 상세 기록 링크를 약 60줄로 유지하라.\n"
|
|
f" step 기록에는 변경 파일, 결정, 검증, 실패한 접근과 이유를 적고 원본 로그를 복사하지 마라.\n"
|
|
f"6. phases/{self._phase_dir_name}/index.json의 해당 step status를 업데이트하라:\n"
|
|
f" - AC 통과 → \"completed\" + \"summary\" 필드에 이 step의 산출물을 한 줄로 요약\n"
|
|
f" - 이번 세션에서 해결하지 못한 실패 → \"error\" + \"error_message\"에 원인·시도한 수정 기록\n"
|
|
f" - 사용자 개입이 필요한 경우 (API 키, 인증, 수동 설정 등) → \"blocked\" + \"blocked_reason\" 기록 후 즉시 중단\n"
|
|
f" 새 세션 재시도는 실행기가 최초 호출 포함 최대 {self.MAX_RETRIES}회 관리한다.\n"
|
|
f" 상태가 인계와 다르면 index와 실제 변경·로그를 확인하라.\n"
|
|
f"7. 변경사항을 직접 커밋하지 마라. Git 커밋과 timestamp는 실행기가 처리한다."
|
|
)
|
|
|
|
# --- Codex 호출 ---
|
|
|
|
def _invoke_codex(self, step: dict, prompt: str) -> dict:
|
|
step_num, step_name = step["step"], step["name"]
|
|
child_env = os.environ.copy()
|
|
child_env.pop("HARNESS_DOCS_ONLY_PHASE", None)
|
|
if self._validation_mode(step) == "docs-only":
|
|
child_env["HARNESS_DOCS_ONLY_PHASE"] = f"phases/{self._phase_dir_name}"
|
|
command = [
|
|
"codex",
|
|
"exec",
|
|
"--json",
|
|
"--sandbox",
|
|
"workspace-write",
|
|
"--dangerously-bypass-hook-trust",
|
|
"--cd",
|
|
self._root,
|
|
"-",
|
|
]
|
|
try:
|
|
result = subprocess.run(
|
|
command,
|
|
cwd=self._root,
|
|
input=prompt,
|
|
env=child_env,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=1800,
|
|
)
|
|
except FileNotFoundError as exc:
|
|
raise CodexEnvironmentError(
|
|
"Codex CLI를 찾을 수 없습니다. codex를 설치하고 PATH를 확인하세요."
|
|
) from exc
|
|
except subprocess.TimeoutExpired as exc:
|
|
stdout = exc.stdout or ""
|
|
stderr = exc.stderr or ""
|
|
if isinstance(stdout, bytes):
|
|
stdout = stdout.decode("utf-8", errors="replace")
|
|
if isinstance(stderr, bytes):
|
|
stderr = stderr.decode("utf-8", errors="replace")
|
|
result = subprocess.CompletedProcess(
|
|
command, 124, stdout, f"Codex timed out after 1800 seconds\n{stderr}",
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
print(f"\n WARN: Codex가 비정상 종료됨 (code {result.returncode})")
|
|
if result.stderr:
|
|
print(f" stderr: {result.stderr[:500]}")
|
|
|
|
output = {
|
|
"step": step_num, "name": step_name,
|
|
"exitCode": result.returncode,
|
|
"stdout": result.stdout, "stderr": result.stderr,
|
|
}
|
|
out_path = self._phase_dir / f"step{step_num}-output.json"
|
|
self._write_json(out_path, output)
|
|
|
|
return output
|
|
|
|
@staticmethod
|
|
def _codex_environment_failure(output: dict) -> Optional[str]:
|
|
if output.get("exitCode", 0) == 0:
|
|
return None
|
|
diagnostic = (
|
|
f"{output.get('stderr', '')}\n{output.get('stdout', '')}"
|
|
).lower()
|
|
markers = {
|
|
"not logged in": "Codex 인증이 필요합니다.",
|
|
"authentication": "Codex 인증에 실패했습니다.",
|
|
"unexpected argument": "현재 Codex CLI가 필요한 옵션을 지원하지 않습니다.",
|
|
"unrecognized option": "현재 Codex CLI가 필요한 옵션을 지원하지 않습니다.",
|
|
}
|
|
for marker, message in markers.items():
|
|
if marker in diagnostic:
|
|
return message
|
|
return None
|
|
|
|
# --- 헤더 & 검증 ---
|
|
|
|
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 _validate_completed_step(self, step: dict) -> tuple[bool, str]:
|
|
# Stop can halt a Codex turn with exit 0 even when its build failed.
|
|
# Independently verify before accepting the agent's completed state.
|
|
from hooks.stop_validation import _documentation_only_changes, run_validations
|
|
|
|
root = Path(self._root)
|
|
deadline = time.monotonic() + 1800
|
|
if self._validation_mode(step) == "docs-only" and _documentation_only_changes(
|
|
root, f"phases/{self._phase_dir_name}", deadline=deadline,
|
|
):
|
|
return True, ""
|
|
return run_validations(root, deadline=deadline)
|
|
|
|
def _execute_single_step(self, step: dict) -> 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 = next(s for s in index["steps"] if s["step"] == step_num)
|
|
prompt = self._build_prompt(step, 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:
|
|
output = self._invoke_codex(step, prompt)
|
|
elapsed = int(pi.elapsed)
|
|
|
|
index = self._read_json(self._index_file)
|
|
if output["exitCode"] != 0:
|
|
for s in index["steps"]:
|
|
if s["step"] == step_num and s["status"] == "completed":
|
|
s["status"] = "error"
|
|
s.pop("completed_at", None)
|
|
s.pop("summary", None)
|
|
self._write_json(self._index_file, index)
|
|
environment_failure = self._codex_environment_failure(output)
|
|
if environment_failure:
|
|
raise CodexEnvironmentError(environment_failure)
|
|
|
|
status = next((s.get("status", "pending") for s in index["steps"] if s["step"] == step_num), "pending")
|
|
if status == "completed" and output["exitCode"] == 0:
|
|
with progress_indicator("Verifying completed step"):
|
|
valid, validation_error = self._validate_completed_step(step)
|
|
if not valid:
|
|
status = "error"
|
|
current = next(s for s in index["steps"] if s["step"] == step_num)
|
|
current.update(status="error", error_message=validation_error)
|
|
current.pop("completed_at", None)
|
|
current.pop("summary", None)
|
|
self._write_json(self._index_file, index)
|
|
ts = self._stamp()
|
|
|
|
if status == "completed" and output["exitCode"] == 0:
|
|
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)
|
|
|
|
current = next(s for s in index["steps"] if s["step"] == step_num)
|
|
reason = current.get("error_message") or (
|
|
f"Codex exited with code {output['exitCode']}"
|
|
if output["exitCode"] else "Step did not update status"
|
|
)
|
|
diagnostics = [reason]
|
|
for stream in ("stderr", "stdout"):
|
|
if output.get(stream, "").strip():
|
|
diagnostics.append(f"{stream}:\n{output[stream].strip()}")
|
|
err_msg = self._bounded_error("\n\n".join(diagnostics))
|
|
|
|
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):
|
|
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)
|
|
|
|
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)")
|
|
mode = parser.add_mutually_exclusive_group()
|
|
mode.add_argument("--push", action="store_true", help="Push branch after completion")
|
|
mode.add_argument("--dry-run", action="store_true", help="Preview first pending step context without execution")
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
StepExecutor(args.phase_dir, auto_push=args.push).run(dry_run=args.dry_run)
|
|
except (CodexEnvironmentError, ContextError) as exc:
|
|
print(f"ERROR: {exc}")
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|