modify coding template
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 +1,42 @@
|
||||
---
|
||||
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.
|
||||
description: Use when reviewing this Harness repository: local changes, generated phase files, step outputs, implementation diffs, missing tests, build readiness, or compliance with AGENTS.md, docs/ARCHITECTURE.md, docs/ADR.md, and Harness acceptance criteria.
|
||||
---
|
||||
|
||||
# Harness Review
|
||||
|
||||
Use this skill when the user wants a repository-grounded review instead of generic commentary.
|
||||
## Overview
|
||||
|
||||
## Review input set
|
||||
Use this skill to review Harness work against the repository's persistent rules, architecture docs, and executable verification requirements. Prioritize bugs, regressions, missing tests, and rule violations.
|
||||
|
||||
Read these first:
|
||||
## Review Process
|
||||
|
||||
- `/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.
|
||||
1. Read `/AGENTS.md`, `/docs/ARCHITECTURE.md`, and `/docs/ADR.md`.
|
||||
2. Inspect the changed files with `git status --short` and `git diff`.
|
||||
3. Check architecture, stack choices, tests, critical rules, and build readiness.
|
||||
4. Run relevant verification commands when feasible. If a command cannot be run, report that as residual risk.
|
||||
5. Lead with actionable findings. Keep summaries secondary.
|
||||
|
||||
## Checklist
|
||||
|
||||
Evaluate the patch against these questions:
|
||||
| Item | Question |
|
||||
| --- | --- |
|
||||
| Architecture | Does the change follow `docs/ARCHITECTURE.md` directory and module boundaries? |
|
||||
| Stack | Does the change stay within choices documented in `docs/ADR.md`? |
|
||||
| Tests | Are new or changed behaviors covered by tests? |
|
||||
| Critical Rules | Does the change violate any `AGENTS.md` CRITICAL rule? |
|
||||
| Build | Do relevant build/test/lint commands pass? |
|
||||
|
||||
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 Format
|
||||
|
||||
## Output rules
|
||||
If there are findings, list them first in severity order with file and line references when possible. Then include this table:
|
||||
|
||||
- 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.
|
||||
| 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 아키텍처 준수 | PASS/FAIL | {상세} |
|
||||
| 기술 스택 준수 | PASS/FAIL | {상세} |
|
||||
| 테스트 존재 | PASS/FAIL | {상세} |
|
||||
| CRITICAL 규칙 | PASS/FAIL | {상세} |
|
||||
| 빌드 가능 | PASS/FAIL | {상세} |
|
||||
|
||||
## 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.
|
||||
When there are no findings, say that clearly, then mention any commands not run or remaining risk.
|
||||
|
||||
@@ -1,4 +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."
|
||||
short_description: "Review Harness changes safely"
|
||||
default_prompt: "Use $harness-review to review Harness repository changes."
|
||||
|
||||
@@ -1,47 +1,36 @@
|
||||
---
|
||||
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.
|
||||
description: Use when planning or running this Harness framework: reading AGENTS.md and docs/*.md, discussing implementation scope, creating or updating phases/index.json, phases/{task}/index.json, phases/{task}/stepN.md, or invoking scripts/execute.py for staged Codex execution.
|
||||
---
|
||||
|
||||
# Harness Workflow
|
||||
|
||||
Use this skill when the user is working in the Harness template and wants structured planning or phase-file generation.
|
||||
## Overview
|
||||
|
||||
Use this skill to turn a user-approved task into small, self-contained Harness steps that another Codex session can execute reliably. Keep the workflow grounded in repository docs and executable acceptance criteria.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Explore first
|
||||
1. Read `AGENTS.md` and relevant files under `docs/`, especially `docs/PRD.md`, `docs/ARCHITECTURE.md`, and `docs/ADR.md`.
|
||||
2. Discuss unresolved product or technical decisions with the user before writing phase files.
|
||||
3. When the user asks for an implementation plan, draft steps and get approval before creating files.
|
||||
4. Create or update `phases/index.json`, `phases/{task-name}/index.json`, and one `phases/{task-name}/stepN.md` per step.
|
||||
5. Run the phase with `python scripts/execute.py {task-name}` when asked to execute it. Use `--push` only when the user asks to push.
|
||||
|
||||
Read these files before proposing steps:
|
||||
## Step Design Rules
|
||||
|
||||
- `/AGENTS.md`
|
||||
- `/docs/PRD.md`
|
||||
- `/docs/ARCHITECTURE.md`
|
||||
- `/docs/ADR.md`
|
||||
- `/docs/UI_GUIDE.md`
|
||||
- Scope each step to one layer or module. Split steps when multiple modules would otherwise change together.
|
||||
- Make every step self-contained. Do not rely on prior conversation; include all required context and file paths.
|
||||
- Force context gathering. Each step must tell Codex which docs and previous outputs to read before editing.
|
||||
- Specify interfaces and signatures, not full implementations, unless exact code is required for a constraint.
|
||||
- Put core invariants directly in the step: idempotency, security, data integrity, API contracts, or other non-negotiables.
|
||||
- Use executable acceptance criteria such as `npm run build && npm test`, not abstract statements.
|
||||
- Write cautions concretely: "Do not do X. Reason: Y."
|
||||
- Name steps with kebab-case slugs such as `project-setup`, `api-layer`, or `auth-flow`.
|
||||
|
||||
If the user explicitly asks for parallel exploration, use built-in Codex subagents such as `explorer`, or the repo-scoped custom agent `phase_planner`.
|
||||
## Phase Files
|
||||
|
||||
### 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.
|
||||
Create or update `phases/index.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -54,16 +43,12 @@ Top-level phase registry. Append to `phases[]` when the file already exists.
|
||||
}
|
||||
```
|
||||
|
||||
- `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`
|
||||
Create `phases/{task-name}/index.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"project": "<project-name>",
|
||||
"phase": "<phase-name>",
|
||||
"phase": "<task-name>",
|
||||
"steps": [
|
||||
{ "step": 0, "name": "project-setup", "status": "pending" },
|
||||
{ "step": 1, "name": "core-types", "status": "pending" },
|
||||
@@ -72,74 +57,68 @@ Top-level phase registry. Append to `phases[]` when the file already exists.
|
||||
}
|
||||
```
|
||||
|
||||
- `project`: from `AGENTS.md`.
|
||||
- `phase`: directory name.
|
||||
- `steps[].step`: zero-based integer.
|
||||
- `steps[].name`: kebab-case slug.
|
||||
- `steps[].status`: initialize to `pending`.
|
||||
Rules:
|
||||
|
||||
### `phases/{phase}/stepN.md`
|
||||
- `project` comes from `AGENTS.md`.
|
||||
- `phase` matches the task directory name.
|
||||
- `steps[].step` starts at `0`.
|
||||
- Initial status is always `pending`.
|
||||
- Do not add timestamps when creating files. `scripts/execute.py` records `created_at`, `started_at`, `completed_at`, `failed_at`, and `blocked_at`.
|
||||
|
||||
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:
|
||||
## Step Template
|
||||
|
||||
```markdown
|
||||
# Step {N}: {name}
|
||||
|
||||
## Read First
|
||||
- /AGENTS.md
|
||||
- /docs/ARCHITECTURE.md
|
||||
- /docs/ADR.md
|
||||
- {files from previous steps}
|
||||
## 읽어야 할 파일
|
||||
|
||||
## Task
|
||||
{specific instructions}
|
||||
먼저 아래 파일들을 읽고 프로젝트의 아키텍처와 설계 의도를 파악하라:
|
||||
|
||||
- `/AGENTS.md`
|
||||
- `/docs/ARCHITECTURE.md`
|
||||
- `/docs/ADR.md`
|
||||
- {previously created or modified files}
|
||||
|
||||
이전 step에서 만들어진 코드를 꼼꼼히 읽고, 설계 의도를 이해한 뒤 작업하라.
|
||||
|
||||
## 작업
|
||||
|
||||
{Concrete instructions with file paths, interfaces, signatures, and rules.}
|
||||
|
||||
## 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
|
||||
npm run build
|
||||
npm test
|
||||
```
|
||||
|
||||
`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`
|
||||
1. 위 AC 커맨드를 실행한다.
|
||||
2. 아키텍처 체크리스트를 확인한다:
|
||||
- ARCHITECTURE.md 디렉토리 구조를 따르는가?
|
||||
- ADR 기술 스택을 벗어나지 않았는가?
|
||||
- AGENTS.md CRITICAL 규칙을 위반하지 않았는가?
|
||||
3. 결과에 따라 `phases/{task-name}/index.json`의 해당 step을 업데이트한다:
|
||||
- 성공: `"status": "completed"`, `"summary": "산출물 한 줄 요약"`
|
||||
- 3회 수정 시도 후 실패: `"status": "error"`, `"error_message": "구체적 에러 내용"`
|
||||
- 사용자 개입 필요: `"status": "blocked"`, `"blocked_reason": "구체적 사유"` 후 중단
|
||||
|
||||
## 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.
|
||||
- {Do not do X. Reason: Y.}
|
||||
- 기존 테스트를 깨뜨리지 마라.
|
||||
```
|
||||
|
||||
## Execution And Recovery
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python scripts/execute.py {task-name}
|
||||
python scripts/execute.py {task-name} --push
|
||||
```
|
||||
|
||||
`scripts/execute.py` creates or checks out `feat-{task-name}`, injects `AGENTS.md` and `docs/*.md` into each prompt, carries completed step summaries forward, retries failed steps up to three times, separates code and metadata commits, and records timestamps.
|
||||
|
||||
If a step is `error`, set it back to `pending` and remove `error_message` after fixing the cause. If a step is `blocked`, resolve `blocked_reason`, set it back to `pending`, remove `blocked_reason`, and rerun.
|
||||
|
||||
@@ -1,4 +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."
|
||||
short_description: "Plan staged Harness workflow steps"
|
||||
default_prompt: "Use $harness-workflow to plan Harness 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 +1,4 @@
|
||||
# Project-scoped Codex defaults for the Harness template.
|
||||
# As of 2026-04-15, hooks are experimental and disabled on native Windows.
|
||||
#:schema https://developers.openai.com/codex/config-schema.json
|
||||
|
||||
[features]
|
||||
codex_hooks = true
|
||||
|
||||
[agents]
|
||||
max_threads = 6
|
||||
max_depth = 1
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"matcher": "^Bash$",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/pre_tool_use_policy.py\"",
|
||||
"statusMessage": "Checking risky shell command"
|
||||
"command": "python -c \"import pathlib, runpy, subprocess; root = pathlib.Path(subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], text=True).strip()); runpy.run_path(str(root / '.codex' / 'hooks' / 'pre_commit_checks.py'), run_name='__main__')\"",
|
||||
"timeout": 600,
|
||||
"statusMessage": "Running pre-commit checks"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
},
|
||||
{
|
||||
"matcher": "^(apply_patch|Edit|Write)$",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/stop_continue.py\"",
|
||||
"statusMessage": "Running Harness validation",
|
||||
"timeout": 300
|
||||
"command": "python -c \"import pathlib, runpy, subprocess; root = pathlib.Path(subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], text=True).strip()); runpy.run_path(str(root / '.codex' / 'hooks' / 'tdd-guard.py'), run_name='__main__')\"",
|
||||
"timeout": 30,
|
||||
"statusMessage": "Checking TDD guard"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,111 @@
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CHECKS = ("lint", "build", "test")
|
||||
|
||||
|
||||
def _repo_root(cwd: Path) -> Path:
|
||||
try:
|
||||
root = subprocess.check_output(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
cwd=cwd,
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return cwd
|
||||
return Path(root)
|
||||
|
||||
|
||||
def _load_scripts(root: Path) -> dict[str, str]:
|
||||
package_json = root / "package.json"
|
||||
if not package_json.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
package = json.loads(package_json.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
_deny(f"Invalid package.json: {exc}")
|
||||
raise SystemExit(0) from exc
|
||||
|
||||
scripts = package.get("scripts", {})
|
||||
if not isinstance(scripts, dict):
|
||||
return {}
|
||||
return {str(name): str(command) for name, command in scripts.items()}
|
||||
|
||||
|
||||
def _is_git_commit(command: str) -> bool:
|
||||
return re.search(r"\bgit(?:\s+(?:-[A-Za-z]\s+\S+|--[A-Za-z0-9-]+(?:=\S+)?))*\s+commit\b", command) is not None
|
||||
|
||||
|
||||
def _deny(reason: str) -> None:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": reason,
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _tail(text: str, limit: int = 1200) -> str:
|
||||
text = text.strip()
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[-limit:]
|
||||
|
||||
|
||||
def _run_checks(root: Path, scripts: dict[str, str]) -> str | None:
|
||||
npm = shutil.which("npm") or shutil.which("npm.cmd")
|
||||
if npm is None:
|
||||
return "npm was not found, so pre-commit checks could not run."
|
||||
|
||||
for check in CHECKS:
|
||||
if check not in scripts:
|
||||
continue
|
||||
result = subprocess.run(
|
||||
[npm, "run", check],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
details = _tail(result.stdout + "\n" + result.stderr)
|
||||
if details:
|
||||
return f"npm run {check} failed:\n{details}"
|
||||
return f"npm run {check} failed with exit code {result.returncode}."
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
except json.JSONDecodeError:
|
||||
return 0
|
||||
|
||||
command = payload.get("tool_input", {}).get("command", "")
|
||||
if not isinstance(command, str) or not _is_git_commit(command):
|
||||
return 0
|
||||
|
||||
cwd = Path(payload.get("cwd") or Path.cwd())
|
||||
root = _repo_root(cwd)
|
||||
failure = _run_checks(root, _load_scripts(root))
|
||||
if failure:
|
||||
_deny(f"PRE-COMMIT CHECKS: {failure}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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())
|
||||
@@ -0,0 +1,189 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SOURCE_SUFFIXES = {".ts", ".tsx", ".js", ".jsx"}
|
||||
TEST_SUFFIXES = ("ts", "tsx", "js", "jsx")
|
||||
CONFIG_SUFFIXES = {".json", ".css", ".scss", ".md", ".yml", ".yaml"}
|
||||
NEXT_SPECIAL_FILES = {
|
||||
"layout.ts",
|
||||
"layout.tsx",
|
||||
"page.ts",
|
||||
"page.tsx",
|
||||
"loading.tsx",
|
||||
"error.tsx",
|
||||
"not-found.tsx",
|
||||
"globals.css",
|
||||
}
|
||||
|
||||
|
||||
def _repo_root(cwd: Path) -> Path:
|
||||
try:
|
||||
root = subprocess.check_output(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
cwd=cwd,
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return cwd
|
||||
return Path(root)
|
||||
|
||||
|
||||
def _extract_patch_paths(command: str) -> list[str]:
|
||||
prefixes = (
|
||||
"*** Add File: ",
|
||||
"*** Update File: ",
|
||||
"*** Delete File: ",
|
||||
"*** Move to: ",
|
||||
)
|
||||
paths: list[str] = []
|
||||
for raw_line in command.splitlines():
|
||||
line = raw_line.strip()
|
||||
for prefix in prefixes:
|
||||
if line.startswith(prefix):
|
||||
paths.append(line[len(prefix) :].strip())
|
||||
break
|
||||
return paths
|
||||
|
||||
|
||||
def _touched_paths(payload: dict) -> list[str]:
|
||||
tool_input = payload.get("tool_input", {})
|
||||
if not isinstance(tool_input, dict):
|
||||
return []
|
||||
|
||||
file_path = tool_input.get("file_path")
|
||||
if isinstance(file_path, str) and file_path:
|
||||
return [file_path]
|
||||
|
||||
command = tool_input.get("command")
|
||||
if isinstance(command, str):
|
||||
return _extract_patch_paths(command)
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _normalize(path_text: str) -> str:
|
||||
return path_text.replace("\\", "/").lower()
|
||||
|
||||
|
||||
def _is_test_path(path_text: str) -> bool:
|
||||
normalized = _normalize(path_text)
|
||||
name = normalized.rsplit("/", 1)[-1]
|
||||
return (
|
||||
"__tests__/" in normalized
|
||||
or ".test." in name
|
||||
or ".spec." in name
|
||||
or "test" in name
|
||||
or "spec" in name
|
||||
)
|
||||
|
||||
|
||||
def _is_exempt(path_text: str) -> bool:
|
||||
normalized = _normalize(path_text)
|
||||
path = Path(path_text)
|
||||
name = path.name.lower()
|
||||
|
||||
if _is_test_path(path_text):
|
||||
return True
|
||||
if name in NEXT_SPECIAL_FILES:
|
||||
return True
|
||||
if path.suffix.lower() in CONFIG_SUFFIXES:
|
||||
return True
|
||||
if ".env" in name or ".config." in name:
|
||||
return True
|
||||
if any(token in name for token in ("tailwind", "postcss", "next.config", "tsconfig")):
|
||||
return True
|
||||
if "/types/" in normalized or name in {"types.ts", "types.d.ts"}:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_path(path_text: str, cwd: Path) -> Path:
|
||||
path = Path(path_text)
|
||||
if path.is_absolute():
|
||||
return path
|
||||
return (cwd / path).resolve()
|
||||
|
||||
|
||||
def _base_name(path: Path) -> str:
|
||||
for suffix in (".tsx", ".ts", ".jsx", ".js"):
|
||||
if path.name.endswith(suffix):
|
||||
return path.name[: -len(suffix)]
|
||||
return path.stem
|
||||
|
||||
|
||||
def _has_existing_test(path: Path, root: Path) -> bool:
|
||||
directory = path.parent
|
||||
parent = directory.parent
|
||||
base = _base_name(path)
|
||||
|
||||
for ext in TEST_SUFFIXES:
|
||||
if (directory / f"{base}.test.{ext}").exists():
|
||||
return True
|
||||
if (directory / f"{base}.spec.{ext}").exists():
|
||||
return True
|
||||
|
||||
for ext in TEST_SUFFIXES:
|
||||
if (parent / "__tests__" / f"{base}.test.{ext}").exists():
|
||||
return True
|
||||
if (directory / "__tests__" / f"{base}.test.{ext}").exists():
|
||||
return True
|
||||
|
||||
for ext in TEST_SUFFIXES:
|
||||
if (root / "src" / "__tests__" / f"{base}.test.{ext}").exists():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _guarded_paths(paths: list[str], cwd: Path, root: Path) -> list[str]:
|
||||
missing_tests: list[str] = []
|
||||
for path_text in paths:
|
||||
if _is_exempt(path_text):
|
||||
continue
|
||||
|
||||
path = _resolve_path(path_text, cwd)
|
||||
if path.suffix.lower() not in SOURCE_SUFFIXES:
|
||||
continue
|
||||
if not _has_existing_test(path, root):
|
||||
missing_tests.append(_base_name(path))
|
||||
|
||||
return missing_tests
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
except json.JSONDecodeError:
|
||||
return 0
|
||||
|
||||
cwd = Path(payload.get("cwd") or Path.cwd())
|
||||
root = _repo_root(cwd)
|
||||
missing_tests = _guarded_paths(_touched_paths(payload), cwd, root)
|
||||
if not missing_tests:
|
||||
return 0
|
||||
|
||||
names = ", ".join(sorted(set(missing_tests)))
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": (
|
||||
"TDD GUARD: missing test file for "
|
||||
f"{names}. Write or add the test first."
|
||||
),
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
next-env.d.ts
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# phase execution outputs
|
||||
phases/**/phase*-output.json
|
||||
phases/**/step*-output.json
|
||||
+6
-25
@@ -1,11 +1,4 @@
|
||||
# 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)}
|
||||
@@ -17,24 +10,12 @@
|
||||
- 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` 환경 변수에 줄바꿈 기준으로 검증 커맨드를 지정
|
||||
- 커밋 메시지는 conventional commits 형식을 따를 것 (feat:, fix:, docs:, refactor:)
|
||||
|
||||
## 명령어
|
||||
- `python scripts/execute.py <phase-dir>`: Codex 기반 phase 순차 실행
|
||||
- `python scripts/execute.py <phase-dir> --push`: phase 완료 후 브랜치 push
|
||||
- `python scripts/validate_workspace.py`: 저장소 검증
|
||||
npm run dev # 개발 서버
|
||||
npm run build # 프로덕션 빌드
|
||||
npm run lint # ESLint
|
||||
npm run test # 테스트
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Agentic-AI-Template
|
||||
|
||||
@@ -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.
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Harness Step Executor - run phase steps sequentially with Codex and self-correction.
|
||||
Harness Step Executor — phase 내 step을 순차 실행하고 자가 교정한다.
|
||||
|
||||
Usage:
|
||||
python scripts/execute.py <phase-dir> [--push]
|
||||
@@ -9,6 +9,7 @@ Usage:
|
||||
import argparse
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
@@ -53,7 +54,7 @@ class StepExecutor:
|
||||
"""Phase 디렉토리 안의 step들을 순차 실행하는 하네스."""
|
||||
|
||||
MAX_RETRIES = 3
|
||||
FEAT_MSG = "feat({phase}): step {num} - {name}"
|
||||
FEAT_MSG = "feat({phase}): step {num} — {name}"
|
||||
CHORE_MSG = "chore({phase}): step {num} output"
|
||||
TZ = timezone(timedelta(hours=9))
|
||||
|
||||
@@ -177,9 +178,7 @@ class StepExecutor:
|
||||
sections = []
|
||||
agents_md = ROOT / "AGENTS.md"
|
||||
if agents_md.exists():
|
||||
sections.append(
|
||||
f"## 프로젝트 규칙 (AGENTS.md)\n\n{agents_md.read_text(encoding='utf-8')}"
|
||||
)
|
||||
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")):
|
||||
@@ -199,6 +198,9 @@ class StepExecutor:
|
||||
|
||||
def _build_preamble(self, guardrails: str, step_context: str,
|
||||
prev_error: Optional[str] = None) -> str:
|
||||
commit_example = self.FEAT_MSG.format(
|
||||
phase=self._phase_name, num="N", name="<step-name>"
|
||||
)
|
||||
retry_section = ""
|
||||
if prev_error:
|
||||
retry_section = (
|
||||
@@ -218,7 +220,8 @@ class StepExecutor:
|
||||
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"
|
||||
f"6. 모든 변경사항을 커밋하라:\n"
|
||||
f" {commit_example}\n\n---\n\n"
|
||||
)
|
||||
|
||||
# --- Codex 호출 ---
|
||||
@@ -232,14 +235,9 @@ class StepExecutor:
|
||||
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,
|
||||
["codex", "exec", "--dangerously-bypass-approvals-and-sandbox", "--json", prompt],
|
||||
cwd=self._root, capture_output=True, text=True, timeout=1800,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
@@ -247,14 +245,9 @@ class StepExecutor:
|
||||
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"
|
||||
|
||||
@@ -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