From 0d5b982af8c4f86fe9b7867e0bf16765a8ec2c60 Mon Sep 17 00:00:00 2001 From: NINI Date: Fri, 17 Apr 2026 00:08:11 +0900 Subject: [PATCH] initial commit --- .agents/plugins/marketplace.json | 20 + .agents/skills/harness-review/SKILL.md | 57 ++ .../skills/harness-review/agents/openai.yaml | 4 + .agents/skills/harness-workflow/SKILL.md | 145 +++++ .../harness-workflow/agents/openai.yaml | 4 + .codex/agents/harness-reviewer.toml | 11 + .codex/agents/phase-planner.toml | 12 + .codex/config.toml | 9 + .codex/hooks.json | 28 + .../pre_tool_use_policy.cpython-312.pyc | Bin 0 -> 1562 bytes .../__pycache__/stop_continue.cpython-312.pyc | Bin 0 -> 2018 bytes .codex/hooks/pre_tool_use_policy.py | 47 ++ .codex/hooks/stop_continue.py | 55 ++ .gitignore | 10 + AGENTS.md | 40 ++ README.md | 2 +- docs/ADR.md | 21 + docs/ARCHITECTURE.md | 26 + docs/PRD.md | 21 + .../.codex-plugin/plugin.json | 22 + .../harness-engineering/agents/openai.yaml | 4 + .../harness-engineering/commands/harness.md | 43 ++ .../harness-engineering/commands/review.md | 38 ++ scripts/__pycache__/execute.cpython-312.pyc | Bin 0 -> 25704 bytes .../validate_workspace.cpython-312.pyc | Bin 0 -> 3922 bytes scripts/execute.py | 424 +++++++++++++ scripts/test_execute.py | 562 ++++++++++++++++++ scripts/validate_workspace.py | 91 +++ 28 files changed, 1695 insertions(+), 1 deletion(-) create mode 100644 .agents/plugins/marketplace.json create mode 100644 .agents/skills/harness-review/SKILL.md create mode 100644 .agents/skills/harness-review/agents/openai.yaml create mode 100644 .agents/skills/harness-workflow/SKILL.md create mode 100644 .agents/skills/harness-workflow/agents/openai.yaml create mode 100644 .codex/agents/harness-reviewer.toml create mode 100644 .codex/agents/phase-planner.toml create mode 100644 .codex/config.toml create mode 100644 .codex/hooks.json create mode 100644 .codex/hooks/__pycache__/pre_tool_use_policy.cpython-312.pyc create mode 100644 .codex/hooks/__pycache__/stop_continue.cpython-312.pyc create mode 100644 .codex/hooks/pre_tool_use_policy.py create mode 100644 .codex/hooks/stop_continue.py create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 docs/ADR.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/PRD.md create mode 100644 plugins/harness-engineering/.codex-plugin/plugin.json create mode 100644 plugins/harness-engineering/agents/openai.yaml create mode 100644 plugins/harness-engineering/commands/harness.md create mode 100644 plugins/harness-engineering/commands/review.md create mode 100644 scripts/__pycache__/execute.cpython-312.pyc create mode 100644 scripts/__pycache__/validate_workspace.cpython-312.pyc create mode 100644 scripts/execute.py create mode 100644 scripts/test_execute.py create mode 100644 scripts/validate_workspace.py diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..056725e --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "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" + } + ] +} diff --git a/.agents/skills/harness-review/SKILL.md b/.agents/skills/harness-review/SKILL.md new file mode 100644 index 0000000..c0fcf8d --- /dev/null +++ b/.agents/skills/harness-review/SKILL.md @@ -0,0 +1,57 @@ +--- +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. diff --git a/.agents/skills/harness-review/agents/openai.yaml b/.agents/skills/harness-review/agents/openai.yaml new file mode 100644 index 0000000..555439e --- /dev/null +++ b/.agents/skills/harness-review/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Harness Review" + short_description: "Review changes against Harness project rules" + default_prompt: "Use Harness review to check architecture, tests, and rules." diff --git a/.agents/skills/harness-workflow/SKILL.md b/.agents/skills/harness-workflow/SKILL.md new file mode 100644 index 0000000..6d6f4c2 --- /dev/null +++ b/.agents/skills/harness-workflow/SKILL.md @@ -0,0 +1,145 @@ +--- +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": "", + "phase": "", + "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 +python scripts/execute.py --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. diff --git a/.agents/skills/harness-workflow/agents/openai.yaml b/.agents/skills/harness-workflow/agents/openai.yaml new file mode 100644 index 0000000..890daa1 --- /dev/null +++ b/.agents/skills/harness-workflow/agents/openai.yaml @@ -0,0 +1,4 @@ +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." diff --git a/.codex/agents/harness-reviewer.toml b/.codex/agents/harness-reviewer.toml new file mode 100644 index 0000000..95263fd --- /dev/null +++ b/.codex/agents/harness-reviewer.toml @@ -0,0 +1,11 @@ +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. +""" diff --git a/.codex/agents/phase-planner.toml b/.codex/agents/phase-planner.toml new file mode 100644 index 0000000..35d5389 --- /dev/null +++ b/.codex/agents/phase-planner.toml @@ -0,0 +1,12 @@ +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. +""" diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..39ca33a --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,9 @@ +# 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 diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..166e5b9 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,28 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/pre_tool_use_policy.py\"", + "statusMessage": "Checking risky shell command" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/stop_continue.py\"", + "statusMessage": "Running Harness validation", + "timeout": 300 + } + ] + } + ] + } +} diff --git a/.codex/hooks/__pycache__/pre_tool_use_policy.cpython-312.pyc b/.codex/hooks/__pycache__/pre_tool_use_policy.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2f329e6f4e007d549479fa87944ec67f2ab67aac GIT binary patch literal 1562 zcmZuxOH3O_7@mFj{Qx@#B^6P&fu?M!O;jZZq9_p$E1``58QLn=R;yj_fY)B{S~IhN zEszlE0i;S4^*|~`sw%Z=D=sP?n;ww;L}hI~afTQ-oz zBAuE!R+yY(u9@MHJ9*~GXeK+YTQUe_USgOIWZv_dS%5Wf9%jc0tZ8QM4b99JV97_A zhm)FVK}R#$ib1TIrDJ3cMVN7kW8Y*rZGi|N$9Mry5lE!sXSTuM#+QxbrEP+bw zblehcuzg(hf8&xRx8utKiMK0^fe)quSQbe~+f(UmYcJ)JxFnEd+peVB+Dp03I^~3g zkJJ-=W{$ikxfQVZY=y1JeaRM1blrV2>qu{(SO5ZO2r~P60r@Ab!V&%t^#LRa;4aHt z0E^OHupp?>3B-Y+QQ~=yZo74#(9rQDQb5yX&#O+?VacAgOOrm~;ph~CGc5aE45@5E zx6!of4q6uC(KM!3pVbmZ^*CDFe7_*@s zW4m!l&z|-b*z|g?8mDrFNh&*@VNnS;Fb!a0*)HvPdwOz$RbX!?kC2C`GzSSyj@+3X zeMg@fo|(xPCZ?%~APr(@pi-Ge$8Swc7V@LR(|Oj8U#qhSu{@u!Lx$=`gKvTO&~b|p zh+NFCQ&E%`y*a+mqQ+anS{*_C5JewDz3w@7sgbKUs9ZB_7oBHI_#NUe8CwA@Q3|B? z(%oC?rh0$$v++;IchZ;ELai|9=-#NVSMR;~wYZZUc$^%3lpK5z`Sz`y;ZGn0QcpTMTN0BmaEhID zwRfZsJnTC&a!qow2v9e4x1&P@K3z}FdL zXB32A!R_C{#b+S)6hxkaXe$^_iz~_f?k*wGx(wv_TJs0#+>>Z#bLNxxcA`Ds%RMa# K$giMFtia#py^Mwc literal 0 HcmV?d00001 diff --git a/.codex/hooks/__pycache__/stop_continue.cpython-312.pyc b/.codex/hooks/__pycache__/stop_continue.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e706fdf7384c15e6c56105f196c9b4d25c64f458 GIT binary patch literal 2018 zcmZ`4O>YxNbk@7}I=lW@CjmkN$(EFlMaU9}s%=zNsz_R;hqU6WU?El;&xEXF@47Q% zVjD*av=YgQpdLsCRq3T4N)P=J?WvbIMv56|MGHM}b4v~tr_MOp7}0jDo%iO=dvD&i ze~!mBz*p+`U)`1hz+-U;7C(z6J%fC|1t7=(D8W!(Dugm2N#Jr`&d6=3WRx}xXTlsR z`A9*{sGLWI;ZK!J6m`MK%pm}9JOq%&zvxO^HvlWrCUQ-x$X2v^?qta`F)I4Rr9Q5h zmz}%|9qRg?IXjI!(=ku@5S2|@!k$T}UnHjEK`!xe2@z`g9x@9)MrP3=#B`@jmzqRpoT^4SC9TIML0lH-(069*1dC{#f@!QDD zcOCcUKn>*df8WDU5CAMyBpMGm{)bx*xXTg|DNurQE*VI6U=ZRtsV2d2mA?)nVxJ4u zLb+6jFTetRA}r|%sJY$%>%bR$-r1oB7~LIUwQcV%g}4nW|3$y=S_yC7&&f48*B|h9 zAe7pr!mZ%=arB)aUPS(8p9bMuKK=9J63nx94U9}$>mW_^#k zUJ0c&%?hz7L|K>cNx&i)ImGv@UZ&(YWJ=z5Ad^XjurQ&}^;q)#Qy)x>qAZ^kV;K9G z$(IntqEnfQ5%KdpG7(gE2_;M;rO6`pvxpET)MJuH-~#bN_ieqQYsW2Tp^+* zOtGHtO^bKR7eo@tg?2u-<4~+HrQo<8KFrx;{3CDh3Y-V6kQ`1u?B03Hx@p}x@#XmE zaYld|<+t3fM>&NQ)v4(zp zeza-yE>-G=xjH;jH%8{in|klMK2X;O?&*7le0@SqK2f6A z$FGh*>Kl4woNM;FO6~nqjdW=n3BWDW7PXC3S2)t@1+nPqQY3&rQd;PoE>0G z2u0s2_^^~mZ{yeaQH&PyCf~TRlJpz6_y^ec80Zf`>H*Mx0e#OR2c>xHv;>scLUm2v aeP8RndHS=n4Q 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()) diff --git a/.codex/hooks/stop_continue.py b/.codex/hooks/stop_continue.py new file mode 100644 index 0000000..e61f2ae --- /dev/null +++ b/.codex/hooks/stop_continue.py @@ -0,0 +1,55 @@ +#!/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()) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d45dfd --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.next/ +out/ +next-env.d.ts +tsconfig.tsbuildinfo + +# phase execution outputs +phases/**/phase*-output.json +phases/**/step*-output.json +phases/**/step*-last-message.txt diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..37a55bf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +# Project: {프로젝트명} + +## Repository Role +- This repository is a Codex-first Harness Engineering template. +- Persistent repository instructions live in this `AGENTS.md`. +- Reusable repo-scoped workflows live in `.agents/skills/`. +- Project-scoped custom agents live in `.codex/agents/`. +- Experimental hooks live in `.codex/hooks.json`. + +## 기술 스택 +- {프레임워크 (예: Next.js 15)} +- {언어 (예: TypeScript strict mode)} +- {스타일링 (예: Tailwind CSS)} + +## 아키텍처 규칙 +- CRITICAL: {절대 지켜야 할 규칙 1 (예: 모든 API 로직은 app/api/ 라우트 핸들러에서만 처리)} +- CRITICAL: {절대 지켜야 할 규칙 2 (예: 클라이언트 컴포넌트에서 직접 외부 API를 호출하지 말 것)} +- {일반 규칙 (예: 컴포넌트는 components/ 폴더에, 타입은 types/ 폴더에 분리)} + +## Harness Workflow +- 먼저 `docs/PRD.md`, `docs/ARCHITECTURE.md`, `docs/ADR.md`, `docs/UI_GUIDE.md`를 읽고 기획/설계 의도를 파악할 것 +- 단계별 실행 계획이 필요하면 repo skill `harness-workflow`를 사용해 `phases/` 아래 파일을 설계할 것 +- 변경사항 리뷰가 필요하면 repo skill `harness-review` 또는 Codex의 `/review`를 사용할 것 +- `phases/{phase}/index.json`은 phase 진행 상태의 단일 진실 공급원으로 취급할 것 +- 각 `stepN.md`는 독립된 Codex 세션에서도 실행 가능하도록 자기완결적으로 작성할 것 + +## 개발 프로세스 +- CRITICAL: 새 기능 구현 시 반드시 테스트를 먼저 작성하고, 테스트가 통과하는 구현을 작성할 것 (TDD) +- 커밋 메시지는 conventional commits 형식을 따를 것 (`feat:`, `fix:`, `docs:`, `refactor:`) +- `scripts/execute.py`는 step 완료 후 코드/메타데이터 커밋을 정리하므로, step 프롬프트 안에서 별도 커밋을 만들 필요는 없음 + +## 검증 +- 기본 검증 스크립트는 `python scripts/validate_workspace.py` +- Node 프로젝트면 `package.json`의 `lint`, `build`, `test` 스크립트를 자동 탐지해 순서대로 실행 +- 다른 스택이면 `HARNESS_VALIDATION_COMMANDS` 환경 변수에 줄바꿈 기준으로 검증 커맨드를 지정 + +## 명령어 +- `python scripts/execute.py `: Codex 기반 phase 순차 실행 +- `python scripts/execute.py --push`: phase 완료 후 브랜치 push +- `python scripts/validate_workspace.py`: 저장소 검증 diff --git a/README.md b/README.md index 1fb938e..cad8f9e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,2 @@ -# fesa +# Agentic-AI-Template diff --git a/docs/ADR.md b/docs/ADR.md new file mode 100644 index 0000000..216ad6d --- /dev/null +++ b/docs/ADR.md @@ -0,0 +1,21 @@ +# Architecture Decision Records + +## 철학 +{프로젝트의 핵심 가치관 (예: MVP 속도 최우선. 외부 의존성 최소화. 작동하는 최소 구현을 선택.)} + +--- + +### ADR-001: {결정 사항 (예: Next.js App Router 선택)} +**결정**: {뭘 선택했는지} +**이유**: {왜 선택했는지} +**트레이드오프**: {뭘 포기했는지} + +### ADR-002: {결정 사항} +**결정**: {뭘 선택했는지} +**이유**: {왜 선택했는지} +**트레이드오프**: {뭘 포기했는지} + +### ADR-003: {결정 사항} +**결정**: {뭘 선택했는지} +**이유**: {왜 선택했는지} +**트레이드오프**: {뭘 포기했는지} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..9e90592 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,26 @@ +# 아키텍처 + +## 디렉토리 구조 +``` +src/ +├── Analysis/ # Analysis 관련 class +├── Property/ # 요소 재료 및 속성 관련 class +├── Element/ # 요소 관련 class +├── Boundary/ # 경계조건 관련 class +├── Load/ # 하중 관련 class +└── Util/ # 수학 라이브러리 등 솔버 utility 관련 class +``` + +## 패턴 +{사용하는 디자인 패턴 (예: Server Components 기본, 인터랙션이 필요한 곳만 Client Component)} + +## 데이터 흐름 +``` +해석 입력 파일 +{데이터가 어떻게 흐르는지 (예: +사용자 입력 → Client Component → API Route → 외부 API → 응답 → UI 업데이트 +)} +``` + +## 상태 관리 +{상태 관리 방식 (예: 서버 상태는 Server Components, 클라이언트 상태는 useState/useReducer)} diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 0000000..b1950bb --- /dev/null +++ b/docs/PRD.md @@ -0,0 +1,21 @@ +# PRD: {프로젝트명} + +## 목표 +{이 프로젝트가 해결하려는 문제를 한 줄로 요약} + +## 사용자 +{누가 이 제품을 쓰는지} + +## 핵심 기능 +1. {기능 1} +2. {기능 2} +3. {기능 3} + +## MVP 제외 사항 +- {안 만들 것 1} +- {안 만들 것 2} +- {안 만들 것 3} + +## 디자인 +- {디자인 방향 (예: 다크모드 고정, 미니멀)} +- {색상 (예: 무채색 + 포인트 1가지)} diff --git a/plugins/harness-engineering/.codex-plugin/plugin.json b/plugins/harness-engineering/.codex-plugin/plugin.json new file mode 100644 index 0000000..60b9951 --- /dev/null +++ b/plugins/harness-engineering/.codex-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "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" + } +} diff --git a/plugins/harness-engineering/agents/openai.yaml b/plugins/harness-engineering/agents/openai.yaml new file mode 100644 index 0000000..2671cba --- /dev/null +++ b/plugins/harness-engineering/agents/openai.yaml @@ -0,0 +1,4 @@ +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." diff --git a/plugins/harness-engineering/commands/harness.md b/plugins/harness-engineering/commands/harness.md new file mode 100644 index 0000000..e4bc9df --- /dev/null +++ b/plugins/harness-engineering/commands/harness.md @@ -0,0 +1,43 @@ +--- +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 ` 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 ` or a focused edit to one generated step. diff --git a/plugins/harness-engineering/commands/review.md b/plugins/harness-engineering/commands/review.md new file mode 100644 index 0000000..568d6e6 --- /dev/null +++ b/plugins/harness-engineering/commands/review.md @@ -0,0 +1,38 @@ +--- +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. diff --git a/scripts/__pycache__/execute.cpython-312.pyc b/scripts/__pycache__/execute.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b92b9852e168e2e19d202ca8480ab07b6d54e620 GIT binary patch literal 25704 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

nC+R#~s?dzi}Nm^^;4JuLGa|<=c~? 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 [--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() diff --git a/scripts/test_execute.py b/scripts/test_execute.py new file mode 100644 index 0000000..1039319 --- /dev/null +++ b/scripts/test_execute.py @@ -0,0 +1,562 @@ +"""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 diff --git a/scripts/validate_workspace.py b/scripts/validate_workspace.py new file mode 100644 index 0000000..f7c0526 --- /dev/null +++ b/scripts/validate_workspace.py @@ -0,0 +1,91 @@ +#!/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())