From 12c6e19122bbdc37a754b477f015c1f515036627 Mon Sep 17 00:00:00 2001 From: jazz1x <33783621+jazz1x@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:40:32 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20v0.1.0=20=E2=80=94=20jq=20=E2=86=92=20P?= =?UTF-8?q?ython=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - harnish_py 패키지 신설: jq 의존 로직 전체를 Python 모듈로 이전, Shell은 thin wrapper - pytest 유닛 테스트 51개 신설 (기존 0개) - promote_pending() 함수 분리 — detect.py stdout 캡처 우회법 제거 - promote-pending O(n²) → O(n): 배치 처리 후 단일 jsonl_rewrite - 안정성 수정: Python 3.11 미만 진입 차단, stdin EOF 경고 제거, migrate 중복 실행 방지 - CI: macOS 단일 매트릭스, Python 테스트 스텝 추가 - docs/README/CHANGELOG v0.1.0 갱신 --- .claude-plugin/plugin.json | 2 +- .github/workflows/tests.yml | 16 +- .gitignore | 6 + CHANGELOG.md | 25 +- README.ko.md | 17 +- README.md | 17 +- VERSION | 2 +- scripts/abstract-asset.sh | 52 +-- scripts/check-thresholds.sh | 38 +-- scripts/check-violations.sh | 34 +- scripts/common.sh | 19 +- scripts/compress-assets.sh | 105 +----- scripts/compress-progress.sh | 146 +------- scripts/detect-asset.sh | 137 +------- scripts/harnish_py/__init__.py | 1 + scripts/harnish_py/__main__.py | 5 + scripts/harnish_py/asset.py | 92 ++++++ scripts/harnish_py/cli.py | 58 ++++ scripts/harnish_py/common.py | 46 +++ scripts/harnish_py/compress.py | 109 ++++++ scripts/harnish_py/detect.py | 171 ++++++++++ scripts/harnish_py/init.py | 41 +++ scripts/harnish_py/io.py | 82 +++++ scripts/harnish_py/migrate.py | 82 +++++ scripts/harnish_py/progress.py | 493 ++++++++++++++++++++++++++++ scripts/harnish_py/promote.py | 166 ++++++++++ scripts/harnish_py/purge.py | 91 +++++ scripts/harnish_py/quality.py | 66 ++++ scripts/harnish_py/query.py | 119 +++++++ scripts/harnish_py/record.py | 138 ++++++++ scripts/harnish_py/skillify.py | 157 +++++++++ scripts/harnish_py/thresholds.py | 49 +++ scripts/harnish_py/violations.py | 52 +++ scripts/init-assets.sh | 46 +-- scripts/localize-asset.sh | 51 +-- scripts/loop-step.sh | 161 +-------- scripts/migrate.sh | 77 +---- scripts/progress-report.sh | 140 +------- scripts/promote-pending.sh | 151 +-------- scripts/purge-assets.sh | 82 +---- scripts/quality-gate.sh | 56 +--- scripts/query-assets.sh | 131 +------- scripts/record-asset.sh | 151 +-------- scripts/skillify.sh | 165 +--------- scripts/validate-progress.sh | 127 +------ skills/drafti-architect/SKILL.ko.md | 2 +- skills/drafti-architect/SKILL.md | 2 +- skills/drafti-feature/SKILL.ko.md | 2 +- skills/drafti-feature/SKILL.md | 2 +- skills/forki/SKILL.ko.md | 2 +- skills/forki/SKILL.md | 2 +- skills/impl/SKILL.ko.md | 4 +- skills/impl/SKILL.md | 4 +- skills/impl/references/schema.json | 2 +- skills/ralphi/SKILL.ko.md | 2 +- skills/ralphi/SKILL.md | 2 +- tests/conftest.py | 8 + tests/e2e_pipeline.bats | 2 +- tests/unit_asset_test.py | 46 +++ tests/unit_cli_test.py | 27 ++ tests/unit_compress_test.py | 68 ++++ tests/unit_detect_test.py | 60 ++++ tests/unit_init_test.py | 28 ++ tests/unit_io_test.py | 66 ++++ tests/unit_migrate_test.py | 63 ++++ tests/unit_progress_test.py | 86 +++++ tests/unit_promote_dedup_test.py | 65 ++++ tests/unit_purge_test.py | 69 ++++ tests/unit_query_filter_test.py | 81 +++++ tests/unit_record_test.py | 41 +++ tests/unit_skillify_test.py | 77 +++++ 71 files changed, 2942 insertions(+), 1843 deletions(-) create mode 100644 scripts/harnish_py/__init__.py create mode 100644 scripts/harnish_py/__main__.py create mode 100644 scripts/harnish_py/asset.py create mode 100644 scripts/harnish_py/cli.py create mode 100644 scripts/harnish_py/common.py create mode 100644 scripts/harnish_py/compress.py create mode 100644 scripts/harnish_py/detect.py create mode 100644 scripts/harnish_py/init.py create mode 100644 scripts/harnish_py/io.py create mode 100644 scripts/harnish_py/migrate.py create mode 100644 scripts/harnish_py/progress.py create mode 100644 scripts/harnish_py/promote.py create mode 100644 scripts/harnish_py/purge.py create mode 100644 scripts/harnish_py/quality.py create mode 100644 scripts/harnish_py/query.py create mode 100644 scripts/harnish_py/record.py create mode 100644 scripts/harnish_py/skillify.py create mode 100644 scripts/harnish_py/thresholds.py create mode 100644 scripts/harnish_py/violations.py create mode 100644 tests/conftest.py create mode 100644 tests/unit_asset_test.py create mode 100644 tests/unit_cli_test.py create mode 100644 tests/unit_compress_test.py create mode 100644 tests/unit_detect_test.py create mode 100644 tests/unit_init_test.py create mode 100644 tests/unit_io_test.py create mode 100644 tests/unit_migrate_test.py create mode 100644 tests/unit_progress_test.py create mode 100644 tests/unit_promote_dedup_test.py create mode 100644 tests/unit_purge_test.py create mode 100644 tests/unit_query_filter_test.py create mode 100644 tests/unit_record_test.py create mode 100644 tests/unit_skillify_test.py diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 46b125e..ef409d3 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "harnish", - "version": "0.0.5", + "version": "0.1.0", "description": "자율 구현 엔진. PRD 생성(drafti) → 자율 구현(harnish) → 점검(ralphi).", "author": { "name": "jazz1x", diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a3d94b7..87f0b65 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,14 +18,22 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.14" - - name: Install bats + jq (macos) + - name: Install bats + jq run: brew install bats-core jq - - name: Run test suite + - name: Install pytest + run: pip install pytest + + - name: Run pytest unit tests + run: PYTHONPATH=scripts python3 -m pytest tests/ -v + env: + PYTHONDONTWRITEBYTECODE: 1 + + - name: Run bats test suite run: bash tests/run.sh - - name: Run deep suite + - name: Run bats deep suite run: HARNISH_RUN_DEEP_TESTS=1 bash tests/run.sh if: success() diff --git a/.gitignore b/.gitignore index 141deaf..ee4e146 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,12 @@ Thumbs.db # Runtime data (per-project, generated in user's CWD) .harnish/ +.galmuri/ + +# Python +__pycache__/ +*.pyc +.pytest_cache/ # Packaged skills *.skill diff --git a/CHANGELOG.md b/CHANGELOG.md index 406b4a8..164957a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.0] - 2026-04-29 + +Big-bang migration: all `jq` usage replaced with Python standard library. External interface (CLI flags, stdin/stdout JSON, exit codes, hooks.json, SKILL.md) is 100% preserved. + +### Changed +- **All 18 scripts**: bash+jq logic replaced with `scripts/harnish_py/` Python package; `.sh` files are now 2-line wrappers (`PYTHONPATH + exec python3 -m harnish_py "$@"`) +- `common.sh`: `require_cmd jq` removed; only `resolve_*` helpers and `slugify` remain +- `scripts/skillify.sh`: `skillify_version` bumped `0.0.5` → `0.1.0` +- CI: Python `3.12` → `3.14`; `pytest` unit step added before bats; `jq` install retained for test infrastructure (`test-all.sh` + bats files use jq for assertions; production scripts no longer depend on jq) +- VERSION + 10 SKILL.md frontmatters: `0.0.5` → `0.1.0` +- `.claude-plugin/plugin.json`: `0.0.4` → `0.1.0` (catches up the missed bump from v0.0.5 release; was a pre-existing inconsistency) + +### Added +- `scripts/harnish_py/` — 18-module Python package (cli, io, common, asset, record, query, init, compress, promote, detect, skillify, quality, thresholds, purge, migrate, progress, violations) +- `tests/conftest.py` — pytest sys.path injection (honne pattern) +- 11 pytest unit test files (35+ tests): io, cli, asset, record, query, compress, promote, skillify, purge, progress, init +- Python 3.14+ version guard (`sys.version_info < (3, 14)` → exit 4) +- `_Parser` class (argparse exit code 1 instead of 2, honne pattern) + +### Removed +- `jq` runtime dependency — `scripts/` no longer calls `jq` anywhere (wrappers are pure delegation) + ## [0.0.5] - 2026-04-28 This release combines the asset-store identity correction with the production pipeline closure originally drafted as 0.0.6. Both ship together as 0.0.5. @@ -152,7 +174,8 @@ First public release. 5 skills + shared script suite + asset infrastructure + au - README 구조 정리 (galmuri 동일 톤): badges, install steps, quickstart, usage, hooks, assets, worktrees, fork & customize, naming, triad - VERSIONING.md, references/* 가이드 -[Unreleased]: https://github.com/jazz1x/harnish/compare/v0.0.5...HEAD +[Unreleased]: https://github.com/jazz1x/harnish/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/jazz1x/harnish/compare/v0.0.5...v0.1.0 [0.0.5]: https://github.com/jazz1x/harnish/compare/v0.0.4...v0.0.5 [0.0.4]: https://github.com/jazz1x/harnish/compare/v0.0.3...v0.0.4 [0.0.3]: https://github.com/jazz1x/harnish/compare/v0.0.2...v0.0.3 diff --git a/README.ko.md b/README.ko.md index 2ec3728..ee46cff 100644 --- a/README.ko.md +++ b/README.ko.md @@ -2,10 +2,11 @@ > Claude Code 플러그인 — 자율 구현 엔진 -![version](https://img.shields.io/badge/version-0.0.5-blue) +![version](https://img.shields.io/badge/version-0.1.0-blue) ![license](https://img.shields.io/badge/license-MIT-green) ![claude-code](https://img.shields.io/badge/claude--code-plugin-purple) -![tests](https://img.shields.io/badge/tests-80%20passing-brightgreen) +![tests](https://img.shields.io/badge/tests-123%20passing-brightgreen) +![python](https://img.shields.io/badge/python-3.14%2B-blue) **harnish** (harness + ish) = "대충 하네스 비스무리한 것" — 작업할수록 똑똑해지는 구현 환경. 실패가 가드레일이 되고, 패턴이 축적되며, 세션과 워크트리가 바뀌어도 맥락이 유실되지 않는다. @@ -34,6 +35,12 @@ ralphi ──→ 어떤 아티팩트든 점검 (PRD, SKILL.md, 스크립트, HITL(보고→대기) 또는 자율(즉시 수정) ``` +## 요구 사항 + +- **Python 3.14+** — 모든 `scripts/*.sh` 의 런타임 (1-line wrapper로 `scripts/harnish_py/` 에 위임; `sys.version_info < (3, 14)` 가드가 하위 버전에서 exit 4 로 거부). +- **Claude Code** — 플러그인 호스트. +- **`jq` 의존성 없음** (v0.1.0 부터). + ## 설치 ### 1. 마켓플레이스 등록 @@ -59,7 +66,7 @@ Claude Code 세션 안에서 실행: 예상 출력: ``` -✓ Installed harnish@0.0.5 — 5 skills registered (forki, drafti-feature, drafti-architect, impl, ralphi) +✓ Installed harnish@0.1.0 — 5 skills registered (forki, drafti-feature, drafti-architect, impl, ralphi) ``` ### 3. 확인 @@ -213,14 +220,14 @@ harnish는 **2단 기억 구조** (two-tier memory)로 동작한다. 각 tier의 | **Tier 1 — Asset Store** (episodic) | `.harnish/harnish-assets.jsonl` | 프로젝트별, 세션 간 누적, TTL purge | 일어난 일을 기록 (failure, pattern, guardrail, snippet, decision) | `query-assets.sh --format inject` 로 컨텍스트 주입 (실제 RAG 경로) | | **Tier 2 — Skills** (procedural) | `skills/*/SKILL.md` | 영구 (소스 트리 버전 관리) | 안정화된 행동 양식 | Claude Code가 자동 로드, 트리거 시 발동 | -`skillify.sh`가 그 다리 — 압축된 Tier-1 자산을 Tier-2 SKILL.md scaffold로 묶는다. v0.0.5부터 scaffold는 **production-grade**: +`skillify.sh`가 그 다리 — 압축된 Tier-1 자산을 Tier-2 SKILL.md scaffold로 묶는다. scaffold는 **production-grade** (v0.0.5 도입, v0.1.0에서 Python으로 재구현): - frontmatter `Triggers:` 가 자산 title에서 자동 추출됨 - body는 자산 타입별 섹션 + 메타 (level / confidence / stability / resolved) - `references/source-assets.jsonl` 로 원본 트레이서빌리티 보존 - §1은 여전히 LLM finalize 필요 (1-3개 가이드라인 도출) — draft generator일 뿐, 자율 graduating 아님 -**Trigger → Record → Skillify 파이프라인 (v0.0.5에서 닫힘):** +**Trigger → Record → Skillify 파이프라인** (v0.0.5에서 닫힘, v0.1.0에서 Python 구현 — `.sh` 는 1-line wrapper): ``` PostToolUseFailure → detect-asset.sh (노이즈 필터) → /tmp/harnish-pending-*.jsonl diff --git a/README.md b/README.md index e72149b..34e83e6 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,11 @@ > Claude Code plugin — autonomous implementation engine -![version](https://img.shields.io/badge/version-0.0.5-blue) +![version](https://img.shields.io/badge/version-0.1.0-blue) ![license](https://img.shields.io/badge/license-MIT-green) ![claude-code](https://img.shields.io/badge/claude--code-plugin-purple) -![tests](https://img.shields.io/badge/tests-80%20passing-brightgreen) +![tests](https://img.shields.io/badge/tests-123%20passing-brightgreen) +![python](https://img.shields.io/badge/python-3.14%2B-blue) **harnish** (harness + ish) — an implementation environment that gets smarter as you work. Failures become guardrails, patterns accumulate, and context persists across sessions and worktrees. @@ -34,6 +35,12 @@ ralphi ──→ inspects any artifact (PRD, SKILL.md, scripts, code) HITL (report → wait) or autonomous (fix immediately) ``` +## Requirements + +- **Python 3.14+** — runtime for all `scripts/*.sh` (they delegate to `scripts/harnish_py/` via 1-line wrappers; the `sys.version_info < (3, 14)` guard exits with code 4 on older interpreters). +- **Claude Code** — plugin host. +- **No `jq` dependency** as of v0.1.0. + ## Install ### 1. Register the marketplace @@ -59,7 +66,7 @@ Expected output: Expected output: ``` -✓ Installed harnish@0.0.5 — 5 skills registered (forki, drafti-feature, drafti-architect, impl, ralphi) +✓ Installed harnish@0.1.0 — 5 skills registered (forki, drafti-feature, drafti-architect, impl, ralphi) ``` ### 3. Verify @@ -214,14 +221,14 @@ harnish runs a **two-tier memory** system. Each tier serves a different role; th | **Tier 1 — Asset Store** (episodic) | `.harnish/harnish-assets.jsonl` | Per-project, accumulates across sessions, TTL-purged | Records what happened (failures, patterns, guardrails, snippets, decisions) | Injected into context on demand via `query-assets.sh --format inject` (this is the actual RAG path) | | **Tier 2 — Skills** (procedural) | `skills/*/SKILL.md` | Permanent (versioned in source tree) | Codifies stable behavior | Auto-loaded by Claude Code as triggerable skills | -`skillify.sh` is the bridge — it bundles compressed Tier-1 assets into a Tier-2 SKILL.md scaffold. As of v0.0.5 the scaffold is **production-grade**: +`skillify.sh` is the bridge — it bundles compressed Tier-1 assets into a Tier-2 SKILL.md scaffold. The scaffold is **production-grade** (since v0.0.5; reimplemented in Python in v0.1.0): - Frontmatter `Triggers:` auto-extracted from asset titles - Body sectioned by asset type, with metadata (level / confidence / stability / resolved) - `references/source-assets.jsonl` preserves originals for traceability - §1 still needs LLM finalization of 1-3 actionable guidelines — draft generator, not autonomous graduation -**Trigger → Record → Skillify pipeline (closed in v0.0.5):** +**Trigger → Record → Skillify pipeline** (closed in v0.0.5; pure-Python implementation in v0.1.0, `.sh` files are 1-line wrappers): ``` PostToolUseFailure → detect-asset.sh (noise filter) → /tmp/harnish-pending-*.jsonl diff --git a/VERSION b/VERSION index bbdeab6..6e8bf73 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.5 +0.1.0 diff --git a/scripts/abstract-asset.sh b/scripts/abstract-asset.sh index 2aa92a0..aba5559 100755 --- a/scripts/abstract-asset.sh +++ b/scripts/abstract-asset.sh @@ -1,51 +1,5 @@ #!/usr/bin/env bash -# abstract-asset.sh — 프로젝트 특정 자산을 범용(generic)으로 추상화 (JSONL 기반) -# -# 사용법: -# abstract-asset.sh --slug "docker-build-cache" [--base-dir .harnish] - -set -euo pipefail - +# abstract-asset.sh — Python migration wrapper (v0.1.0+) +# 실제 구현: scripts/harnish_py/asset.py SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -BASE="$(resolve_base_dir)" -SLUG="" - -while [[ $# -gt 0 ]]; do - case $1 in - --slug) SLUG="$2"; shift 2;; - --base-dir) BASE="$2"; shift 2;; - *) shift;; - esac -done - -if [[ -z "$SLUG" ]]; then - echo "오류: --slug 필수" >&2 - exit 1 -fi - -ASSET_FILE="$BASE/harnish-assets.jsonl" - -if [[ ! -f "$ASSET_FILE" ]]; then - echo "오류: $ASSET_FILE 없음" >&2 - exit 1 -fi - -# 원본 찾기 -ORIGINAL=$(jq -c --arg s "$SLUG" 'select(.slug == $s)' "$ASSET_FILE" 2>/dev/null | head -1) - -if [[ -z "$ORIGINAL" ]]; then - echo "오류: slug '$SLUG' 없음" >&2 - exit 1 -fi - -# scope를 generic으로 변경한 사본 추가 (atomic write) -ABSTRACTED=$(echo "$ORIGINAL" | jq -c '.scope = "generic" | .slug = .slug + "-generic" | .context = .context + " (추상화)"') -TMPRAG=$(mktemp "${ASSET_FILE}.XXXXXX") -trap 'rm -f "$TMPRAG"' EXIT -cp "$ASSET_FILE" "$TMPRAG" -echo "$ABSTRACTED" >> "$TMPRAG" -mv "$TMPRAG" "$ASSET_FILE" - -echo "{\"status\":\"abstracted\",\"slug\":\"${SLUG}-generic\"}" +PYTHONPATH="$SCRIPT_DIR" exec python3 -m harnish_py abstract-asset "$@" diff --git a/scripts/check-thresholds.sh b/scripts/check-thresholds.sh index be9895e..3b7d0e0 100755 --- a/scripts/check-thresholds.sh +++ b/scripts/check-thresholds.sh @@ -1,37 +1,5 @@ #!/usr/bin/env bash -# check-thresholds.sh — JSONL 자산의 태그별 카운트를 확인하고 임계치 도달 여부를 보고한다. -# -# 사용법: -# check-thresholds.sh [--threshold N] # 기본 5 -# check-thresholds.sh --base-dir .harnish - -set -euo pipefail - +# check-thresholds.sh — Python migration wrapper (v0.1.0+) +# 실제 구현: scripts/harnish_py/thresholds.py SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -BASE="$(resolve_base_dir)" -THRESHOLD=5 - -while [[ $# -gt 0 ]]; do - case $1 in - --base-dir) BASE="$2"; shift 2;; - --threshold) THRESHOLD="$2"; shift 2;; - *) shift;; - esac -done - -ASSET_FILE="$BASE/harnish-assets.jsonl" - -if [[ ! -f "$ASSET_FILE" ]] || [[ ! -s "$ASSET_FILE" ]]; then - echo "자산 없음" - exit 0 -fi - -jq -c 'select(.compressed != true) | .tags[]' "$ASSET_FILE" 2>/dev/null \ - | sort | uniq -c | sort -rn \ - | awk -v t="$THRESHOLD" '{ - count=$1; tag=$2; - if (count >= t) printf "%s(%d건) ⚠ 압축 권장\n", tag, count; - else printf "%s(%d건)\n", tag, count; - }' +PYTHONPATH="$SCRIPT_DIR" exec python3 -m harnish_py check-thresholds "$@" diff --git a/scripts/check-violations.sh b/scripts/check-violations.sh index 520d00a..382bebb 100755 --- a/scripts/check-violations.sh +++ b/scripts/check-violations.sh @@ -1,33 +1,5 @@ #!/usr/bin/env bash -# check-violations.sh — harnish-current-work.json 위반/에스컬레이션 확인 -# 사용법: bash check-violations.sh [harnish-current-work.json 경로] - -set -euo pipefail - +# check-violations.sh — Python migration wrapper (v0.1.0+) +# 실제 구현: scripts/harnish_py/violations.py SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -PROGRESS_FILE="${1:-$(resolve_progress_file)}" - -if [[ ! -f "$PROGRESS_FILE" ]]; then - echo "ERROR: $PROGRESS_FILE not found" >&2 - exit 1 -fi - -VIOLATIONS=$(jq '.violations | length' "$PROGRESS_FILE" 2>/dev/null || echo "0") -ESCALATIONS=$(jq '.escalations | length' "$PROGRESS_FILE" 2>/dev/null || echo "0") - -echo "위반 기록: ${VIOLATIONS}건" -echo "에스컬레이션: ${ESCALATIONS}건" - -if [[ "$VIOLATIONS" -gt 0 ]]; then - echo "" - echo "── 위반 내역 ──" - jq -r '.violations[] | " \(.timestamp) | Task \(.task) | \(.violation) | 판단: \(.user_decision // "미결")"' "$PROGRESS_FILE" 2>/dev/null -fi - -if [[ "$ESCALATIONS" -gt 0 ]]; then - echo "" - echo "── 에스컬레이션 내역 ──" - jq -r '.escalations[] | " \(.timestamp) | Task \(.task) | \(.blocked_at)"' "$PROGRESS_FILE" 2>/dev/null -fi +PYTHONPATH="$SCRIPT_DIR" exec python3 -m harnish_py check-violations "$@" diff --git a/scripts/common.sh b/scripts/common.sh index 73c478e..5e4c33f 100755 --- a/scripts/common.sh +++ b/scripts/common.sh @@ -5,28 +5,16 @@ # 역할: 디렉토리·인덱스 관리, 환경 해석, 유틸리티 함수 # 규칙: L2 이상의 스크립트를 호출하지 않는다. # +# v0.1.0: jq 의존 제거 — 모든 JSON 처리는 Python(harnish_py)으로 이전됨. +# 이 파일은 resolve_* 헬퍼와 slugify만 남긴다. +# # 사용법 (다른 스크립트에서): # SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # source "$SCRIPT_DIR/common.sh" -# ═══════════════════════════════════════ -# 의존성 체크 -# ═══════════════════════════════════════ -require_cmd() { - local cmd="$1" install_hint="${2:-}" - if ! command -v "$cmd" &>/dev/null; then - echo "오류: '$cmd'이(가) 설치되어 있지 않습니다.${install_hint:+ $install_hint}" >&2 - exit 1 - fi -} - -require_cmd jq "brew install jq" - # ═══════════════════════════════════════ # 환경 해석 # ═══════════════════════════════════════ -# 이 파일이 source된 스크립트의 SCRIPT_DIR을 기준으로 한다. -# SCRIPT_DIR은 source하기 전에 설정되어야 한다. # 자산 루트 경로 해석 # 우선순위: ASSET_BASE_DIR > CWD/.harnish @@ -87,4 +75,3 @@ slugify() { echo "$hash" fi } - diff --git a/scripts/compress-assets.sh b/scripts/compress-assets.sh index 075480e..d1917eb 100755 --- a/scripts/compress-assets.sh +++ b/scripts/compress-assets.sh @@ -1,104 +1,5 @@ #!/usr/bin/env bash -# compress-assets.sh — 같은 태그 N건 이상 자산을 압축 (JSONL 기반) -# -# 사용법: -# compress-assets.sh --tag api [--base-dir .harnish] -# compress-assets.sh --all [--threshold 5] - -set -euo pipefail - +# compress-assets.sh — Python migration wrapper (v0.1.0+) +# 실제 구현: scripts/harnish_py/compress.py SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -BASE="$(resolve_base_dir)" -TAG="" ALL=false THRESHOLD=5 DRY_RUN=false - -while [[ $# -gt 0 ]]; do - case $1 in - --tag) TAG="$2"; shift 2;; - --all) ALL=true; shift;; - --threshold) THRESHOLD="$2"; shift 2;; - --dry-run) DRY_RUN=true; shift;; - --base-dir) BASE="$2"; shift 2;; - *) shift;; - esac -done - -ASSET_FILE="$BASE/harnish-assets.jsonl" - -if [[ ! -f "$ASSET_FILE" ]] || [[ ! -s "$ASSET_FILE" ]]; then - echo '{"status":"empty","compressed":0}' - exit 0 -fi - -# 대상 태그 결정 -if $ALL; then - TAGS_OVER=$(jq -c 'select(.compressed != true) | .tags[]' "$ASSET_FILE" 2>/dev/null \ - | sort | uniq -c | sort -rn \ - | awk -v t="$THRESHOLD" '$1 >= t {print $2}' | tr -d '"') -elif [[ -n "$TAG" ]]; then - TAGS_OVER="$TAG" -else - echo "오류: --tag 또는 --all 필수" >&2 - exit 1 -fi - -if [[ -z "$TAGS_OVER" ]]; then - echo '{"status":"no_targets","compressed":0}' - exit 0 -fi - -# 마킹/요약 append 로직 앞에: -if $DRY_RUN; then - # candidates JSON 출력만, 파일 변경 없음 - CANDIDATES=$(echo "$TAGS_OVER" | awk 'NF' | while read -r t; do - cnt=$(jq -c --arg t "$t" 'select(.compressed != true) | select(.tags[] == $t)' "$ASSET_FILE" | wc -l | xargs) - jq -n -c --arg tag "$t" --argjson count "$cnt" --argjson threshold "$THRESHOLD" \ - '{tag:$tag, count:$count, would_compress:($count >= $threshold)}' - done | jq -s .) - jq -n -c --argjson c "$CANDIDATES" '{status:"dry_run",candidates:$c}' - exit 0 -fi - -COMPRESSED=0 -TMPFILE=$(mktemp) -trap 'rm -f "$TMPFILE" "${TMPFILE}.new"' EXIT -cp "$ASSET_FILE" "$TMPFILE" - -while IFS= read -r target_tag; do - [[ -z "$target_tag" ]] && continue - - COUNT=$(jq -c --arg t "$target_tag" 'select(.compressed != true) | select(.tags[] == $t)' "$TMPFILE" 2>/dev/null | wc -l | xargs) - - if [[ "$COUNT" -lt "$THRESHOLD" && "$ALL" == "true" ]]; then - continue - fi - - # 압축 전: 대상 자산 타이틀 수집 (compressed:true 마킹 이전에 읽어야 함) - TITLES=$(jq -rc --arg t "$target_tag" \ - 'select(.compressed != true) | select(.tags[] == $t) | "\(.type): \(.title)"' \ - "$TMPFILE" 2>/dev/null | head -5 | paste -s -d '|' -) - - # 원본에 compressed:true 추가 - jq -c --arg t "$target_tag" 'if (.compressed != true) and (.tags | any(. == $t)) then . + {compressed: true} else . end' "$TMPFILE" > "${TMPFILE}.new" - mv "${TMPFILE}.new" "$TMPFILE" - - # 요약본 1건 추가 - SUMMARY=$(jq -n -c \ - --arg type "pattern" \ - --arg slug "compressed-${target_tag}" \ - --arg title "[압축] ${target_tag} (${COUNT}건)" \ - --argjson tags "$(jq -n -c --arg t "$target_tag" '[$t]')" \ - --arg date "$(date +%Y-%m-%d)" \ - --arg scope "generic" \ - --arg body "[${target_tag} × ${COUNT}건 압축] ${TITLES}" \ - --arg context "compress-assets.sh" \ - --arg session "compress" \ - '{type:$type,slug:$slug,title:$title,tags:$tags,date:$date,scope:$scope,body:$body,context:$context,session:$session,compressed_summary:true}') - - echo "$SUMMARY" >> "$TMPFILE" - ((COMPRESSED++)) || true -done <<< "$TAGS_OVER" - -mv "$TMPFILE" "$ASSET_FILE" -echo "{\"status\":\"compressed\",\"compressed\":${COMPRESSED}}" +PYTHONPATH="$SCRIPT_DIR" exec python3 -m harnish_py compress-assets "$@" diff --git a/scripts/compress-progress.sh b/scripts/compress-progress.sh index 12544cb..b6534e9 100755 --- a/scripts/compress-progress.sh +++ b/scripts/compress-progress.sh @@ -1,145 +1,5 @@ #!/usr/bin/env bash -# compress-progress.sh — harnish-current-work.json Done 섹션 압축 + JSONL 아카이브 -# -# 역할: -# 완료된 Phase를 harnish-current-work.json에서 compressed stub으로 축약하고, -# 상세 내용은 .progress-archive/phases.jsonl 에 한 줄(JSON)로 저장한다. -# -# 트리거: -# A. milestone: Phase 완료 직후 — 해당 Phase를 정확히 압축 -# bash compress-progress.sh ./harnish-current-work.json --trigger milestone --phase 1 -# -# B. count: 카운터 기반 — Done에 미압축 완료 Phase가 있으면 압축 -# bash compress-progress.sh ./harnish-current-work.json --trigger count -# -# 옵션: -# --trigger milestone|count -# --phase N 압축할 Phase 번호 (milestone 트리거 시 필수) -# --dry-run 실제 변경 없이 출력만 - -set -euo pipefail - +# compress-progress.sh — Python migration wrapper (v0.1.0+) +# 실제 구현: scripts/harnish_py/progress.py SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -PROGRESS_FILE="${1:-$(resolve_progress_file)}" -TRIGGER="count" -TARGET_PHASE="" -DRY_RUN=false - -# ── 의존성 체크 ── -if ! command -v jq &>/dev/null; then - echo "오류: jq가 설치되어 있지 않습니다. brew install jq" >&2 - exit 1 -fi - -shift || true -while [[ $# -gt 0 ]]; do - case "$1" in - --trigger) TRIGGER="$2"; shift 2 ;; - --phase) TARGET_PHASE="$2"; shift 2 ;; - --dry-run) DRY_RUN=true; shift ;; - *) echo "알 수 없는 옵션: $1" >&2; exit 1 ;; - esac -done - -if [[ ! -f "$PROGRESS_FILE" ]]; then - echo "ERROR: $PROGRESS_FILE 없음" >&2; exit 1 -fi - -if ! jq empty "$PROGRESS_FILE" 2>/dev/null; then - echo "ERROR: 유효한 JSON이 아닙니다: $PROGRESS_FILE" >&2; exit 1 -fi - -if [[ "$TRIGGER" == "milestone" && -z "$TARGET_PHASE" ]]; then - echo "ERROR: --trigger milestone 사용 시 --phase N 필요" >&2; exit 1 -fi - -PROGRESS_DIR="$(dirname "$PROGRESS_FILE")" -ARCHIVE_JSONL="${PROGRESS_DIR}/harnish-progress-archive.jsonl" - -# ── 압축할 Phase 목록 결정 ── -PHASES_TO_COMPRESS=() -if [[ "$TRIGGER" == "milestone" ]]; then - PHASES_TO_COMPRESS=("$TARGET_PHASE") -else - while IFS= read -r phase_num; do - [[ -n "$phase_num" ]] && PHASES_TO_COMPRESS+=("$phase_num") - done < <(jq -r '.done.phases[] | select(.compressed != true) | .phase' "$PROGRESS_FILE" 2>/dev/null || true) -fi - -if [[ ${#PHASES_TO_COMPRESS[@]} -eq 0 ]]; then - echo "ℹ️ 압축할 Phase 없음"; exit 0 -fi - -echo "🗜 압축 대상 Phase: ${PHASES_TO_COMPRESS[*]}" - -# ── 아카이브 디렉토리 + 백업 ── -[[ "$DRY_RUN" == false ]] && true -[[ "$DRY_RUN" == false ]] && cp "$PROGRESS_FILE" "${PROGRESS_FILE}.backup" - -CURRENT_JSON=$(cat "$PROGRESS_FILE") - -for PHASE_NUM in "${PHASES_TO_COMPRESS[@]}"; do - # Phase 데이터 존재 확인 - PHASE_EXISTS=$(echo "$CURRENT_JSON" | jq --argjson p "$PHASE_NUM" \ - '[.done.phases[] | select(.phase == $p and .compressed != true)] | length') - - if [[ "$PHASE_EXISTS" -eq 0 ]]; then - echo " Phase ${PHASE_NUM}: 미압축 블록 없음 — 건너뜀"; continue - fi - - # Phase 메타데이터 추출 - PHASE_DATA=$(echo "$CURRENT_JSON" | jq --argjson p "$PHASE_NUM" \ - '.done.phases[] | select(.phase == $p and .compressed != true)') - - PHASE_TITLE=$(echo "$PHASE_DATA" | jq -r '.title // "Phase"') - TASK_COUNT=$(echo "$PHASE_DATA" | jq '[.tasks[]] | length') - TASK_IDS=$(echo "$PHASE_DATA" | jq -r '[.tasks[].id] | join(",")') - CHANGED_FILES=$(echo "$PHASE_DATA" | jq -r '[.tasks[].files_changed[] // empty] | unique | join(",")') - COMPRESSED_AT="$(date -Iseconds 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%SZ)" - - # JSONL 레코드 생성 - JSON_RECORD=$(echo "$PHASE_DATA" | jq -c --arg at "$COMPRESSED_AT" '{ - phase: .phase, - title: .title, - compressed_at: $at, - tasks_completed: (.tasks | length), - task_ids: [.tasks[].id], - files_changed: [.tasks[].files_changed[] // empty] | unique, - milestone_approved_at: .milestone_approved_at - }') - - if [[ "$DRY_RUN" == true ]]; then - echo " [dry-run] JSONL 레코드: ${JSON_RECORD}" - else - echo "${JSON_RECORD}" >> "$ARCHIVE_JSONL" - echo " ✅ Phase ${PHASE_NUM} → ${ARCHIVE_JSONL} 에 append" - fi - - # harnish-current-work.json에서 Phase를 compressed stub으로 교체 - SUMMARY_LINE="tasks:${TASK_COUNT} | files:${CHANGED_FILES:-없음}" - ARCHIVE_REF="harnish-progress-archive.jsonl#phase=${PHASE_NUM}" - - CURRENT_JSON=$(echo "$CURRENT_JSON" | jq --argjson p "$PHASE_NUM" \ - --arg summary "$SUMMARY_LINE" \ - --arg ref "$ARCHIVE_REF" \ - '(.done.phases |= [.[] | if .phase == $p and .compressed != true then - {phase: .phase, title: .title, compressed: true, compressed_summary: $summary, archive_ref: $ref} - else . end])') -done - -# ── 변경 적용 ── -if [[ "$DRY_RUN" == false ]]; then - TMP_PROGRESS=$(mktemp "${PROGRESS_FILE}.XXXXXX") - trap 'rm -f "$TMP_PROGRESS"' EXIT - echo "$CURRENT_JSON" > "$TMP_PROGRESS" && mv "$TMP_PROGRESS" "$PROGRESS_FILE" - - echo "" - echo "🗜 압축 완료" - echo " 아카이브: ${ARCHIVE_JSONL}" - echo " 백업: ${PROGRESS_FILE}.backup" - [[ -f "$ARCHIVE_JSONL" ]] && echo " 누적 레코드: $(wc -l < "$ARCHIVE_JSONL" | tr -d ' ')개 Phase" -else - echo "[dry-run] 실제 변경 없음" -fi +PYTHONPATH="$SCRIPT_DIR" exec python3 -m harnish_py compress-progress "$@" diff --git a/scripts/detect-asset.sh b/scripts/detect-asset.sh index 840ae2d..9c04190 100755 --- a/scripts/detect-asset.sh +++ b/scripts/detect-asset.sh @@ -1,136 +1,5 @@ #!/usr/bin/env bash -# detect-asset.sh — Claude Code hook에서 호출. 자산 감지 + pending 관리. -# -# 노이즈 줄이기: 단순 오류, 테스트 실행, 읽기 전용 작업은 무시. -# pending은 /tmp에 저장 (세션 내 임시 데이터, Asset Store 오염 방지). - -set -euo pipefail - +# detect-asset.sh — Python migration wrapper (v0.1.0+) +# 실제 구현: scripts/harnish_py/detect.py SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -BASE="$(resolve_base_dir)" -ASSET_FILE="$BASE/harnish-assets.jsonl" - -# hook은 조용히 실패해야 함 -trap 'exit 0' ERR - -# .harnish/ 없으면 무시 -[[ -d "$BASE" ]] || exit 0 - -# 세션 해시 -if [[ -n "${CLAUDE_SESSION_ID:-}" ]]; then - SESSION_HASH="$CLAUDE_SESSION_ID" -else - SESSION_HASH=$(echo "$$" | md5 2>/dev/null | cut -c1-8 || echo "$$" | md5sum 2>/dev/null | cut -c1-8 || echo "unknown") -fi -PENDING_FILE="/tmp/harnish-pending-${SESSION_HASH}.jsonl" - -# stdin에서 hook JSON 읽기 (없으면 빈 문자열) -INPUT="" -if [[ ! -t 0 ]]; then - INPUT=$(cat 2>/dev/null || true) -fi - -# JSON이 아니면 무시 -if [[ -z "$INPUT" ]] || ! echo "$INPUT" | jq empty 2>/dev/null; then - # pending 파일이 있으면 보고만 - if [[ -f "$PENDING_FILE" ]] && [[ -s "$PENDING_FILE" ]]; then - PENDING_COUNT=$(wc -l < "$PENDING_FILE" | xargs) - echo "harnish: ${PENDING_COUNT}건 pending 자산 감지됨" - fi - exit 0 -fi - -EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // ""') -TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""') -TOOL_OUTPUT=$(echo "$INPUT" | jq -r '.tool_output // ""') -SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""') - -# ── Stop 이벤트: 임계치 + 품질 게이트 ── -if [[ "$EVENT" == "Stop" ]]; then - # 임계치 확인 - if [[ -f "$ASSET_FILE" ]] && [[ -s "$ASSET_FILE" ]]; then - THRESHOLD_OUT=$(bash "$SCRIPT_DIR/check-thresholds.sh" --base-dir "$BASE" 2>/dev/null || true) - if [[ -n "$THRESHOLD_OUT" ]]; then - echo "$THRESHOLD_OUT" - fi - fi - - # pending → assets 자동 승격 + 삭제 - if [[ -f "$PENDING_FILE" ]] && [[ -s "$PENDING_FILE" ]]; then - PENDING_COUNT=$(wc -l < "$PENDING_FILE" | xargs) - # promote-pending 호출 (dedup 후 record-asset) - PROMOTE_OUT=$(bash "$SCRIPT_DIR/promote-pending.sh" --session "$SESSION_HASH" --base-dir "$BASE" 2>/dev/null || echo '{"promoted":0,"deduplicated":0}') - PROMOTED=$(echo "$PROMOTE_OUT" | jq -r '.promoted // 0' 2>/dev/null || echo "0") - DEDUP=$(echo "$PROMOTE_OUT" | jq -r '.deduplicated // 0' 2>/dev/null || echo "0") - - if [[ "$PROMOTED" -gt 0 ]]; then - echo "harnish: 세션 종료 — pending ${PENDING_COUNT}건 → ${PROMOTED}건 자산 승격 (중복 ${DEDUP}건 통합)" - else - echo "harnish: 세션 종료 — ${PENDING_COUNT}건 pending 처리 실패" - fi - # promote 성공 여부와 무관하게 pending 삭제 (오염 방지; 데이터는 assets에 영속화됨) - rm -f "$PENDING_FILE" - fi - - # 7일 이상 된 stale pending 파일 정리 - find /tmp -maxdepth 1 -name 'harnish-pending-*.jsonl' -mtime +7 -delete 2>/dev/null || true - - exit 0 -fi - -# ── PostToolUseFailure: 의미 있는 에러만 pending에 기록 ── -if [[ "$EVENT" == "PostToolUseFailure" ]]; then - # 노이즈 필터: 단순/일반적 에러는 무시 - NOISE_PATTERNS="No such file|permission denied|command not found|not a directory|Is a directory|syntax error near|unexpected token" - if echo "$TOOL_OUTPUT" | grep -qiE "$NOISE_PATTERNS" 2>/dev/null; then - exit 0 - fi - - # 빈 출력 무시 - if [[ -z "$TOOL_OUTPUT" ]]; then - exit 0 - fi - - # pending 파일 용량 제한 (최대 500줄, 초과 시 최근 250줄만 유지) - MAX_PENDING=500 - if [[ -f "$PENDING_FILE" ]]; then - CURRENT_LINES=$(wc -l < "$PENDING_FILE" | xargs) - if [[ "$CURRENT_LINES" -ge "$MAX_PENDING" ]]; then - TRIMMED=$(mktemp) - tail -250 "$PENDING_FILE" > "$TRIMMED" - mv "$TRIMMED" "$PENDING_FILE" - fi - fi - - # tool_output 크기 제한 (최대 2000자) - if [[ ${#TOOL_OUTPUT} -gt 2000 ]]; then - TOOL_OUTPUT="${TOOL_OUTPUT:0:2000}...(truncated)" - fi - - # 의미 있는 에러 → pending 기록 - PENDING_RECORD=$(jq -n -c \ - --arg event "$EVENT" \ - --arg tool "$TOOL_NAME" \ - --arg output "$TOOL_OUTPUT" \ - --arg session "$SESSION_ID" \ - --arg date "$(date +%Y-%m-%dT%H:%M:%S)" \ - '{event:$event, tool:$tool, output:$output, session:$session, date:$date}') - - echo "$PENDING_RECORD" >> "$PENDING_FILE" - PENDING_COUNT=$(wc -l < "$PENDING_FILE" | xargs) - echo "harnish: 에러 감지 → pending (${PENDING_COUNT}건)" - exit 0 -fi - -# ── PostToolUse: 성공 이벤트는 현재 보고만 ── -if [[ "$EVENT" == "PostToolUse" ]]; then - if [[ -f "$PENDING_FILE" ]] && [[ -s "$PENDING_FILE" ]]; then - PENDING_COUNT=$(wc -l < "$PENDING_FILE" | xargs) - echo "harnish: ${PENDING_COUNT}건 pending 자산 감지됨" - fi - exit 0 -fi - -exit 0 +PYTHONPATH="$SCRIPT_DIR" exec python3 -m harnish_py detect-asset "$@" diff --git a/scripts/harnish_py/__init__.py b/scripts/harnish_py/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/scripts/harnish_py/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/scripts/harnish_py/__main__.py b/scripts/harnish_py/__main__.py new file mode 100644 index 0000000..80bb58e --- /dev/null +++ b/scripts/harnish_py/__main__.py @@ -0,0 +1,5 @@ +import sys +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/harnish_py/asset.py b/scripts/harnish_py/asset.py new file mode 100644 index 0000000..4e379f3 --- /dev/null +++ b/scripts/harnish_py/asset.py @@ -0,0 +1,92 @@ +"""Asset dataclass, type metadata, abstract/localize operations.""" +import sys +import tempfile +from pathlib import Path +from .common import resolve_base_dir, resolve_asset_file +from .io import jsonl_read, jsonl_rewrite, compact_json + +VALID_TYPES = {"failure", "pattern", "guardrail", "snippet", "decision"} + +# Per-type extra fields (mirrors bash case statement in record-asset.sh) +TYPE_EXTRAS: dict[str, dict] = { + "failure": {"resolved": True}, + "pattern": {"stability": 1}, + "snippet": {"stability": 1}, + "guardrail": {"level": "soft"}, + "decision": {"confidence": "medium"}, +} + + +# ── abstract-asset / localize-asset ────────────────────────────────────────── + +def register(sub): + p_abs = sub.add_parser("abstract-asset", help="abstract project asset to generic scope") + p_abs.add_argument("--slug", required=True) + p_abs.add_argument("--base-dir", dest="base_dir", default=None) + p_abs.set_defaults(func=_cmd_abstract) + + p_loc = sub.add_parser("localize-asset", help="localize generic asset to project scope") + p_loc.add_argument("--slug", required=True) + p_loc.add_argument("--base-dir", dest="base_dir", default=None) + p_loc.set_defaults(func=_cmd_localize) + + +def _cmd_abstract(args) -> int: + asset_file = resolve_asset_file(args.base_dir) + if not asset_file.exists(): + sys.stderr.write(f"오류: {asset_file} 없음\n") + return 1 + original = _find_by_slug(asset_file, args.slug) + if original is None: + sys.stderr.write(f"오류: slug '{args.slug}' 없음\n") + return 1 + abstracted = dict(original) + abstracted["scope"] = "generic" + abstracted["slug"] = original["slug"] + "-generic" + abstracted["context"] = original.get("context", "") + " (추상화)" + _append_record(asset_file, abstracted) + print(compact_json({"status": "abstracted", "slug": abstracted["slug"]})) + return 0 + + +def _cmd_localize(args) -> int: + asset_file = resolve_asset_file(args.base_dir) + if not asset_file.exists(): + sys.stderr.write(f"오류: {asset_file} 없음\n") + return 1 + original = _find_by_slug(asset_file, args.slug) + if original is None: + sys.stderr.write(f"오류: slug '{args.slug}' 없음\n") + return 1 + localized = dict(original) + localized["scope"] = "project" + localized["slug"] = original["slug"] + "-local" + localized["context"] = original.get("context", "") + " (로컬화)" + _append_record(asset_file, localized) + print(compact_json({"status": "localized", "slug": localized["slug"]})) + return 0 + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _find_by_slug(asset_file: Path, slug: str) -> "dict | None": + for record in jsonl_read(asset_file): + if record.get("slug") == slug: + return record + return None + + +def _append_record(asset_file: Path, record: dict) -> None: + """Atomic copy+append+rename pattern (mirrors bash record-asset).""" + asset_file.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + dir=asset_file.parent, delete=False, suffix=".tmp", mode="w", encoding="utf-8" + ) as f: + tmp = Path(f.name) + # copy existing + if asset_file.exists(): + with open(asset_file, "r", encoding="utf-8") as src: + for line in src: + f.write(line) + f.write(compact_json(record) + "\n") + tmp.replace(asset_file) diff --git a/scripts/harnish_py/cli.py b/scripts/harnish_py/cli.py new file mode 100644 index 0000000..13726d4 --- /dev/null +++ b/scripts/harnish_py/cli.py @@ -0,0 +1,58 @@ +"""CLI entry point — argparse subcommand router. + +Pattern: each module exposes register(sub) which adds its own subparser. +""" +import argparse +import sys +from typing import Optional, List +from . import __version__ + + +class _Parser(argparse.ArgumentParser): + """ArgumentParser that exits with code 1 (not 2) on bad args.""" + def error(self, message: str) -> None: + self.print_usage(sys.stderr) + self.exit(1, f"error: {message}\n") + + +def main(argv: Optional[List[str]] = None) -> int: + """CLI entry point. Returns exit code.""" + if sys.version_info < (3, 10): + sys.stderr.write("python3>=3.10 required\n") + return 4 + + parser = _Parser( + prog="harnish_py", + description="harnish — autonomous implementation engine", + ) + parser.add_argument( + "--version", action="version", version=f"%(prog)s {__version__}" + ) + + sub = parser.add_subparsers( + dest="command", required=True, parser_class=_Parser + ) + + # Lazy imports — each module registers its own subparser + from . import ( + record, query, init, compress, promote, detect, + skillify, quality, thresholds, purge, migrate, + progress, violations, asset, + ) + record.register(sub) + query.register(sub) + init.register(sub) + compress.register(sub) + promote.register(sub) + detect.register(sub) + skillify.register(sub) + quality.register(sub) + thresholds.register(sub) + purge.register(sub) + migrate.register(sub) + progress.register(sub) + violations.register(sub) + asset.register(sub) # abstract-asset + localize-asset + + args = parser.parse_args(argv) + return args.func(args) diff --git a/scripts/harnish_py/common.py b/scripts/harnish_py/common.py new file mode 100644 index 0000000..47b8190 --- /dev/null +++ b/scripts/harnish_py/common.py @@ -0,0 +1,46 @@ +"""Common helpers — mirrors common.sh resolve_* functions and slugify.""" +import hashlib +import os +import re +from pathlib import Path + + +def resolve_base_dir(base_dir: "str | None" = None) -> Path: + """Priority: explicit arg > ASSET_BASE_DIR env > CWD/.harnish""" + if base_dir: + return Path(base_dir) + env = os.environ.get("ASSET_BASE_DIR") + if env: + return Path(env) + return Path.cwd() / ".harnish" + + +def resolve_progress_file(base_dir: "str | None" = None) -> Path: + return resolve_base_dir(base_dir) / "harnish-current-work.json" + + +def resolve_asset_file(base_dir: "str | None" = None) -> Path: + return resolve_base_dir(base_dir) / "harnish-assets.jsonl" + + +def resolve_legacy_asset_file(base_dir: "str | None" = None) -> Path: + return resolve_base_dir(base_dir) / "harnish-rag.jsonl" + + +def resolve_rag_file(base_dir: "str | None" = None) -> Path: + """Deprecated alias — kept for external caller compatibility.""" + return resolve_asset_file(base_dir) + + +def slugify(text: str) -> str: + """Produce a URL-safe slug. + + 1. Lowercase + replace non-alnum with hyphens, collapse/strip, limit 60. + 2. If empty or only hyphens (i.e. non-ASCII input), fall back to md5[:12]. + Mirrors common.sh slugify() exactly. + """ + ascii_slug = re.sub(r"[^a-z0-9-]", "-", text.lower()) + ascii_slug = re.sub(r"-+", "-", ascii_slug).strip("-")[:60] + if ascii_slug and ascii_slug != "-": + return ascii_slug + return hashlib.md5(text.encode()).hexdigest()[:12] diff --git a/scripts/harnish_py/compress.py b/scripts/harnish_py/compress.py new file mode 100644 index 0000000..ce77696 --- /dev/null +++ b/scripts/harnish_py/compress.py @@ -0,0 +1,109 @@ +"""compress-assets — compress tags with N+ records into a summary entry.""" +import sys +from collections import Counter +from datetime import datetime +from pathlib import Path +from .common import resolve_base_dir, resolve_asset_file +from .io import jsonl_read, jsonl_rewrite, compact_json + + +def register(sub): + p = sub.add_parser("compress-assets", help="compress high-frequency tag groups") + p.add_argument("--tag", default="") + p.add_argument("--all", dest="all_tags", action="store_true", default=False) + p.add_argument("--threshold", type=int, default=5) + p.add_argument("--dry-run", action="store_true", default=False) + p.add_argument("--base-dir", dest="base_dir", default=None) + p.set_defaults(func=_cmd_compress) + + +def _cmd_compress(args) -> int: + if not args.tag and not args.all_tags: + sys.stderr.write("오류: --tag 또는 --all 필수\n") + return 1 + + base = resolve_base_dir(args.base_dir) + asset_file = base / "harnish-assets.jsonl" + + if not asset_file.exists() or asset_file.stat().st_size == 0: + print(compact_json({"status": "empty", "compressed": 0})) + return 0 + + all_records = list(jsonl_read(asset_file)) + uncompressed = [r for r in all_records if not r.get("compressed")] + + # Determine target tags + if args.all_tags: + tag_counts: Counter = Counter() + for r in uncompressed: + for t in r.get("tags", []): + tag_counts[t] += 1 + target_tags = [t for t, c in tag_counts.items() if c >= args.threshold] + elif args.tag: + target_tags = [args.tag] + else: + target_tags = [] + + if not target_tags: + print(compact_json({"status": "no_targets", "compressed": 0})) + return 0 + + if args.dry_run: + candidates = [] + for t in target_tags: + count = sum(1 for r in uncompressed if t in r.get("tags", [])) + candidates.append({ + "tag": t, + "count": count, + "would_compress": count >= args.threshold, + }) + print(compact_json({"status": "dry_run", "candidates": candidates})) + return 0 + + # Compress + compressed_count = 0 + records = list(all_records) # mutable copy + + for target_tag in target_tags: + matching = [r for r in records + if not r.get("compressed") and target_tag in r.get("tags", [])] + count = len(matching) + if count < args.threshold and args.all_tags: + continue + + # Collect titles before marking compressed + titles = " | ".join( + f"{r.get('type')}: {r.get('title')}" + for r in matching[:5] + ) + + # Mark matching records as compressed + new_records = [] + for r in records: + if not r.get("compressed") and target_tag in r.get("tags", []): + r = dict(r) + r["compressed"] = True + new_records.append(r) + records = new_records + + # Add summary entry + date_str = datetime.now().strftime("%Y-%m-%d") + summary = { + "type": "pattern", + "slug": f"compressed-{target_tag}", + "title": f"[압축] {target_tag} ({count}건)", + "tags": [target_tag], + "date": date_str, + "scope": "generic", + "body": f"[{target_tag} × {count}건 압축] {titles}", + "context": "compress-assets.sh", + "session": "compress", + "compressed_summary": True, + } + records.append(summary) + compressed_count += 1 + + jsonl_rewrite(asset_file, records) + + print(compact_json({"status": "compressed", "compressed": compressed_count})) + return 0 diff --git a/scripts/harnish_py/detect.py b/scripts/harnish_py/detect.py new file mode 100644 index 0000000..b1716ba --- /dev/null +++ b/scripts/harnish_py/detect.py @@ -0,0 +1,171 @@ +"""detect-asset — Claude Code hook entry point. + +Reads hook JSON from stdin, routes on hook_event_name. +Must exit 0 even on errors (hook silently fails policy). +""" +import hashlib +import json +import os +import re +import sys +from pathlib import Path +from .common import resolve_base_dir, resolve_asset_file +from .io import compact_json + + +def register(sub): + p = sub.add_parser("detect-asset", help="hook event router (reads stdin)") + p.add_argument("--base-dir", dest="base_dir", default=None) + p.set_defaults(func=_cmd_detect) + + +def _cmd_detect(args) -> int: + try: + return _run(args.base_dir) + except Exception: + return 0 # hooks must never block + + +def _run(base_dir: "str | None") -> int: + base = resolve_base_dir(base_dir) + asset_file = base / "harnish-assets.jsonl" + + # .harnish/ not present → silent no-op + if not base.is_dir(): + return 0 + + # Session hash + session_hash = os.environ.get("CLAUDE_SESSION_ID") or _pid_hash() + pending_file = Path(f"/tmp/harnish-pending-{session_hash}.jsonl") + + # Read stdin + raw = "" + if not sys.stdin.isatty(): + try: + raw = sys.stdin.read() + except Exception: + raw = "" + + # Non-JSON stdin → report pending count only + if not raw: + _report_pending(pending_file) + return 0 + + try: + data = json.loads(raw) + except json.JSONDecodeError: + _report_pending(pending_file) + return 0 + + event = data.get("hook_event_name", "") + tool_name = data.get("tool_name", "") + tool_output = data.get("tool_output", "") + session_id = data.get("session_id", "") + + # ── Stop event ──────────────────────────────────────────────────────────── + if event == "Stop": + if asset_file.exists() and asset_file.stat().st_size > 0: + try: + from .thresholds import check_thresholds_str + thresh_out = check_thresholds_str(base_dir=str(base)) + if thresh_out: + print(thresh_out) + except Exception: + pass + + if pending_file.exists() and pending_file.stat().st_size > 0: + pending_count = _count_lines(pending_file) + try: + from .promote import promote_pending + result = promote_pending(session_hash, str(base), dry_run=False) + promoted = result.get("promoted", 0) + dedup = result.get("deduplicated", 0) + if promoted > 0: + print(f"harnish: 세션 종료 — pending {pending_count}건 → {promoted}건 자산 승격 (중복 {dedup}건 통합)") + else: + print(f"harnish: 세션 종료 — {pending_count}건 pending 처리 실패") + except Exception: + print(f"harnish: 세션 종료 — {pending_count}건 pending 처리 실패") + try: + pending_file.unlink(missing_ok=True) + except Exception: + pass + + # Clean up stale pending files (>7 days) + try: + import glob + import time + cutoff = time.time() - 7 * 86400 + for stale in glob.glob("/tmp/harnish-pending-*.jsonl"): + try: + if os.path.getmtime(stale) < cutoff: + os.unlink(stale) + except Exception: + pass + except Exception: + pass + + return 0 + + # ── PostToolUseFailure ──────────────────────────────────────────────────── + if event == "PostToolUseFailure": + noise = re.compile( + r"No such file|permission denied|command not found|" + r"not a directory|Is a directory|syntax error near|unexpected token", + re.IGNORECASE, + ) + if not tool_output or noise.search(tool_output): + return 0 + + # Trim pending file if too large + if pending_file.exists(): + count = _count_lines(pending_file) + if count >= 500: + lines = pending_file.read_text(encoding="utf-8").splitlines() + pending_file.write_text( + "\n".join(lines[-250:]) + "\n", encoding="utf-8" + ) + + # Truncate output if too long + if len(tool_output) > 2000: + tool_output = tool_output[:2000] + "...(truncated)" + + from datetime import datetime + pending_record = { + "event": event, + "tool": tool_name, + "output": tool_output, + "session": session_id, + "date": datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), + } + with open(pending_file, "a", encoding="utf-8") as f: + f.write(compact_json(pending_record) + "\n") + count = _count_lines(pending_file) + print(f"harnish: 에러 감지 → pending ({count}건)") + return 0 + + # ── PostToolUse ─────────────────────────────────────────────────────────── + if event == "PostToolUse": + _report_pending(pending_file) + return 0 + + return 0 + + +def _pid_hash() -> str: + return hashlib.md5(str(os.getpid()).encode()).hexdigest()[:8] + + +def _count_lines(path: Path) -> int: + count = 0 + with open(path, "r", encoding="utf-8") as f: + for line in f: + if line.strip(): + count += 1 + return count + + +def _report_pending(pending_file: Path) -> None: + if pending_file.exists() and pending_file.stat().st_size > 0: + count = _count_lines(pending_file) + print(f"harnish: {count}건 pending 자산 감지됨") diff --git a/scripts/harnish_py/init.py b/scripts/harnish_py/init.py new file mode 100644 index 0000000..2d2d902 --- /dev/null +++ b/scripts/harnish_py/init.py @@ -0,0 +1,41 @@ +"""init-assets — initialize .harnish/ directory structure.""" +import sys +from pathlib import Path +from .common import resolve_base_dir + + +def register(sub): + p = sub.add_parser("init-assets", help="initialize .harnish/ directory") + p.add_argument("--base-dir", dest="base_dir", default=None) + p.add_argument("--quiet", action="store_true", default=False) + p.set_defaults(func=_cmd_init) + + +def _cmd_init(args) -> int: + return init_assets(base_dir=args.base_dir, quiet=args.quiet) + + +def init_assets(base_dir: "str | None" = None, quiet: bool = False) -> int: + base = resolve_base_dir(base_dir) + base.mkdir(parents=True, exist_ok=True) + + asset_file = base / "harnish-assets.jsonl" + legacy_file = base / "harnish-rag.jsonl" + work_file = base / "harnish-current-work.json" + + # Legacy auto-migration (idempotent) + if legacy_file.exists() and not asset_file.exists(): + legacy_file.rename(asset_file) + if not quiet: + print("ℹ legacy harnish-rag.jsonl → harnish-assets.jsonl 자동 이전") + + if not asset_file.exists(): + asset_file.touch() + + if not work_file.exists(): + work_file.write_text("{}\n", encoding="utf-8") + + if not quiet: + print(f"✓ .harnish/ 초기화 완료 ({base})") + + return 0 diff --git a/scripts/harnish_py/io.py b/scripts/harnish_py/io.py new file mode 100644 index 0000000..af434ef --- /dev/null +++ b/scripts/harnish_py/io.py @@ -0,0 +1,82 @@ +"""I/O helpers — atomic writes, JSONL iteration, SHA-256. + +Mirrors honne's io.py pattern exactly. +""" +import hashlib +import json +import sys +import tempfile +from pathlib import Path +from typing import Iterator + +# Compact JSON — matches jq -c output (no spaces after : and ,) +_COMPACT = {"ensure_ascii": False, "separators": (",", ":")} + + +def compact_json(obj) -> str: + """Serialize to compact JSON (matches jq -c output).""" + return json.dumps(obj, **_COMPACT) + + +def atomic_write(path: "Path | str", data: "bytes | str") -> None: + """Write data to a temporary file, then atomically rename to target.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + if isinstance(data, str): + data = data.encode("utf-8") + with tempfile.NamedTemporaryFile(dir=path.parent, delete=False, suffix=".tmp") as f: + temp_path = Path(f.name) + f.write(data) + temp_path.replace(path) + + +def jsonl_read(path: "Path | str") -> Iterator[dict]: + """Iterate JSONL records. Handles multi-line JSON objects (jq compat). + + Yields nothing if file is missing or empty. + """ + p = Path(path) + if not p.exists(): + return + with open(p, "r", encoding="utf-8") as f: + buf = "" + for line in f: + stripped = line.strip() + if not stripped: + continue + buf += stripped + try: + obj = json.loads(buf) + yield obj + buf = "" + except json.JSONDecodeError: + buf += " " # accumulate for multi-line JSON + if buf.strip(): + sys.stderr.write(f"warning: jsonl_read: incomplete JSON at EOF in {p}\n") + + +def jsonl_append(path: "Path | str", record: dict) -> None: + """Append a single JSONL record. Uses append + flush (no rename).""" + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + with open(p, "a", encoding="utf-8") as f: + f.write(compact_json(record) + "\n") + f.flush() + + +def jsonl_rewrite(path: "Path | str", records: list) -> None: + """Atomically rewrite an entire JSONL file from a list of dicts.""" + lines = "\n".join(compact_json(r) for r in records) + if lines: + lines += "\n" + atomic_write(path, lines) + + +def sha256_file(path: "Path | str") -> str: + """Compute the SHA-256 hex digest of a file.""" + path = Path(path) + hasher = hashlib.sha256() + with open(path, "rb") as f: + while chunk := f.read(8192): + hasher.update(chunk) + return hasher.hexdigest() diff --git a/scripts/harnish_py/migrate.py b/scripts/harnish_py/migrate.py new file mode 100644 index 0000000..4deaefe --- /dev/null +++ b/scripts/harnish_py/migrate.py @@ -0,0 +1,82 @@ +"""migrate — schema backfill for harnish-assets.jsonl.""" +import shutil +import time +from datetime import datetime, timezone +from pathlib import Path +from .common import resolve_base_dir, resolve_asset_file +from .io import jsonl_read, jsonl_rewrite, compact_json + + +def register(sub): + p = sub.add_parser("migrate", help="schema migration with backfill") + p.add_argument("--base-dir", dest="base_dir", default=None) + p.add_argument("--target", default="0.0.2") + p.set_defaults(func=_cmd_migrate) + + +def _cmd_migrate(args) -> int: + base = resolve_base_dir(args.base_dir) + asset_file = base / "harnish-assets.jsonl" + log_file = base / "harnish-migration-log.jsonl" + + if not asset_file.exists(): + print(compact_json({"status": "no-op", "reason": "asset file absent"})) + return 0 + + if asset_file.stat().st_size == 0: + print(compact_json({"status": "no-op", "reason": "asset file empty"})) + return 0 + + now_utc = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + migrated = 0 + skipped = 0 + new_records = [] + + for r in jsonl_read(asset_file): + current_version = r.get("schema_version", "0.0.1") + if current_version == "0.0.1": + r = dict(r) + r["schema_version"] = args.target + r.setdefault("last_accessed_at", r.get("date", "1970-01-01")) + r.setdefault("access_count", 0) + migrated += 1 + else: + skipped += 1 + new_records.append(r) + + if migrated == 0: + print(compact_json({"status": "no-op", "reason": "all records up-to-date", + "skipped": skipped})) + return 0 + + # Backup only when there is actual work to do + now_epoch = int(time.time()) + bak = Path(str(asset_file) + f".bak.{now_epoch}") + shutil.copy2(asset_file, bak) + + jsonl_rewrite(asset_file, new_records) + + # Log + log_entry = { + "ts": now_utc, + "from": "0.0.1", + "to": args.target, + "migrated": migrated, + "skipped": skipped, + "backup": str(bak), + } + with open(log_file, "a", encoding="utf-8") as f: + f.write(compact_json(log_entry) + "\n") + + # Retain only latest 3 backups + bak_files = sorted(asset_file.parent.glob("harnish-assets.jsonl.bak.*"), + key=lambda p: p.stat().st_mtime, reverse=True) + for old_bak in bak_files[3:]: + try: + old_bak.unlink() + except Exception: + pass + + print(compact_json({"status": "migrated", "migrated": migrated, + "skipped": skipped, "backup": str(bak)})) + return 0 diff --git a/scripts/harnish_py/progress.py b/scripts/harnish_py/progress.py new file mode 100644 index 0000000..3c859c7 --- /dev/null +++ b/scripts/harnish_py/progress.py @@ -0,0 +1,493 @@ +"""progress — validate-progress, loop-step, compress-progress, progress-report.""" +import json +import shutil +import sys +import tempfile +from datetime import datetime +from pathlib import Path +from .common import resolve_progress_file +from .io import compact_json + + +def register(sub): + # validate-progress + p_v = sub.add_parser("validate-progress", help="validate harnish-current-work.json") + p_v.add_argument("progress_file", nargs="?", default=None) + p_v.set_defaults(func=_cmd_validate) + + # loop-step + p_l = sub.add_parser("loop-step", help="report ralph loop current coordinates") + p_l.add_argument("progress_file", nargs="?", default=None) + p_l.add_argument("--format", dest="fmt", default="text", choices=["text", "json"]) + p_l.set_defaults(func=_cmd_loop_step) + + # compress-progress + p_c = sub.add_parser("compress-progress", help="compress done phases to archive") + p_c.add_argument("progress_file", nargs="?", default=None) + p_c.add_argument("--trigger", default="count", choices=["count", "milestone"]) + p_c.add_argument("--phase", default=None) + p_c.add_argument("--dry-run", action="store_true", default=False) + p_c.set_defaults(func=_cmd_compress_progress) + + # progress-report + p_r = sub.add_parser("progress-report", help="render progress as markdown") + p_r.add_argument("progress_file", nargs="?", default=None) + p_r.set_defaults(func=_cmd_report) + + +# ── validate-progress ───────────────────────────────────────────────────────── + +def _cmd_validate(args) -> int: + path = _resolve_progress(args.progress_file) + + if not path.exists(): + sys.stderr.write(f"오류: harnish-current-work.json 없음: {path}\n") + return 1 + + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + sys.stderr.write(f"오류: 유효한 JSON이 아닙니다: {path}\n") + return 1 + + errors = [] + warnings = [] + + for key in ("metadata", "done", "doing", "todo"): + if key not in data: + errors.append(f"필수 키 누락: '{key}'") + + meta = data.get("metadata", {}) + for field in ("prd", "started_at", "last_session", "status"): + if not meta.get(field): + errors.append(f"메타데이터 필수 필드 누락: '{field}'") + + emoji = (meta.get("status") or {}).get("emoji", "") + if emoji and emoji not in ("🟢", "🟡", "🔴", "✅", "🔵"): + warnings.append(f"현재 상태에 유효한 상태 이모지(🟢🟡🔴✅) 없음: '{emoji}'") + + doing_task = data.get("doing", {}).get("task") + if doing_task is not None: + for field in ("id", "title", "started_at", "current", "next_action"): + if not doing_task.get(field): + warnings.append(f"진행 중 태스크에 '{field}' 필드 누락 — 세션 복원 정확도 저하") + + done_phases = data.get("done", {}).get("phases", []) + for phase in done_phases: + if phase.get("compressed"): + continue + for task in phase.get("tasks", []): + if not task.get("result"): + warnings.append("완료된 태스크에 'result' 필드 없음") + break + + for key in ("issues", "violations", "escalations", "stats"): + if key not in data: + warnings.append(f"선택 키 누락: '{key}' — 있으면 추적이 용이") + + if errors: + sys.stderr.write("❌ harnish-current-work.json 구조 오류 발견:\n") + for e in errors: + sys.stderr.write(f" • {e}\n") + + if warnings: + sys.stderr.write("⚠️ 경고:\n") + for w in warnings: + sys.stderr.write(f" • {w}\n") + + if errors: + sys.stderr.write(f"❌ 구조 오류 {len(errors)}건, 경고 {len(warnings)}건\n") + return 1 + else: + print(f"✅ harnish-current-work.json 구조 정상 (경고 {len(warnings)}건)") + return 0 + + +# ── loop-step ───────────────────────────────────────────────────────────────── + +def _cmd_loop_step(args) -> int: + path = _resolve_progress(args.progress_file) + fmt = args.fmt + + if not path.exists(): + sys.stderr.write(f"ERROR: harnish-current-work.json not found at '{path}'\n") + sys.stderr.write("HINT: Run harnish Mode A (시딩) first to seed tasks.\n") + return 1 + + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + sys.stderr.write(f"ERROR: 유효한 JSON이 아닙니다: {path}\n") + return 1 + + doing_task = data.get("doing", {}).get("task") + doing_null = doing_task is None + + current_task = (doing_task or {}).get("id", "") + current_title = (doing_task or {}).get("title", "") + next_action = (doing_task or {}).get("next_action", "") + prd_path = data.get("metadata", {}).get("prd", "") + current_phase = (data.get("metadata", {}).get("status") or {}).get("phase", "") + + todo_phases = data.get("todo", {}).get("phases", []) + done_phases = data.get("done", {}).get("phases", []) + + todo_count = sum(len(p.get("tasks", [])) for p in todo_phases) + done_count = sum( + len(p.get("tasks", [])) + for p in done_phases + if not p.get("compressed") + ) + + if doing_null: + status = "NO_DOING" + else: + status = "ACTIVE" + + if todo_count == 0 and status == "NO_DOING": + status = "ALL_DONE" + + # Phase milestone detection + phase_todo = 0 + if current_phase not in (None, "", "null"): + for p in todo_phases: + if p.get("phase") == current_phase: + phase_todo += len(p.get("tasks", [])) + + milestone_reached = False + milestone_phase = "" + last_done_phase = None + uncomp_done = [p for p in done_phases if not p.get("compressed")] + if uncomp_done: + last_done_phase = uncomp_done[-1].get("phase") + + if status == "NO_DOING" and last_done_phase is not None: + remaining = sum( + len(p.get("tasks", [])) + for p in todo_phases + if p.get("phase") == last_done_phase + ) + if remaining == 0: + milestone_reached = True + milestone_phase = last_done_phase + + if fmt == "json": + out = { + "status": status, + "current_task": current_task, + "current_title": current_title, + "current_phase": str(current_phase) if current_phase != "" else "", + "next_action": next_action, + "prd_path": prd_path, + "todo_remaining": todo_count, + "phase_todo_remaining": phase_todo, + "phase_milestone": milestone_reached, + "milestone_phase": str(milestone_phase) if milestone_phase != "" else "", + "done_count": done_count, + } + print(compact_json(out)) + else: + print("════════════════════════════════════") + print(" ralph 루프 현재 좌표") + print("════════════════════════════════════") + print(f" STATUS : {status}") + print(f" Phase : {current_phase or '미설정'}") + print(f" Task ID : {current_task or '없음'}") + print(f" Title : {current_title or '없음'}") + print(f" 다음 액션 : {next_action or '미설정'}") + print(f" PRD : {prd_path or '미설정'}") + print(f" Phase Todo : {phase_todo}개 남음") + print(f" 전체 Todo : {todo_count}개") + print(f" 완료 Done : {done_count}개") + if milestone_reached: + print(f" 마일스톤 : Phase {milestone_phase} 완료!") + print("════════════════════════════════════") + print("") + if status == "ACTIVE": + print(f"→ '{next_action or '다음 액션 미설정'}'부터 실행을 재개합니다.") + elif status == "NO_DOING": + if milestone_reached: + print(f"→ Phase {milestone_phase} 마일스톤 도달. 사용자 승인 대기.") + else: + print("→ Doing이 비어있습니다. Todo에서 다음 태스크를 가져옵니다.") + else: + print("→ 모든 태스크 완료. 최종 보고를 생성합니다.") + + return 0 + + +# ── compress-progress ───────────────────────────────────────────────────────── + +def _cmd_compress_progress(args) -> int: + path = _resolve_progress(args.progress_file) + + if not path.exists(): + sys.stderr.write(f"ERROR: {path} 없음\n") + return 1 + + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + sys.stderr.write(f"ERROR: 유효한 JSON이 아닙니다: {path}\n") + return 1 + + trigger = args.trigger + target_phase_str = args.phase + + if trigger == "milestone" and not target_phase_str: + sys.stderr.write("ERROR: --trigger milestone 사용 시 --phase N 필요\n") + return 1 + + done_phases = data.get("done", {}).get("phases", []) + + # Determine which phases to compress + if trigger == "milestone": + # parse as int or string to match + try: + target = int(target_phase_str) + except (TypeError, ValueError): + target = target_phase_str + phases_to_compress = [ + p for p in done_phases + if not p.get("compressed") and p.get("phase") == target + ] + else: + phases_to_compress = [p for p in done_phases if not p.get("compressed")] + + if not phases_to_compress: + print("ℹ️ 압축할 Phase 없음") + return 0 + + phase_nums = [p.get("phase") for p in phases_to_compress] + print(f"🗜 압축 대상 Phase: {' '.join(str(n) for n in phase_nums)}") + + archive_file = path.parent / "harnish-progress-archive.jsonl" + compressed_at = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + + if not args.dry_run: + shutil.copy2(path, str(path) + ".backup") + + for phase in phases_to_compress: + phase_num = phase.get("phase") + phase_title = phase.get("title", "Phase") + tasks = phase.get("tasks", []) + task_count = len(tasks) + task_ids = [t.get("id", "") for t in tasks] + files_changed = list({ + f for t in tasks for f in (t.get("files_changed") or []) + }) + + json_record = { + "phase": phase_num, + "title": phase_title, + "compressed_at": compressed_at, + "tasks_completed": task_count, + "task_ids": task_ids, + "files_changed": files_changed, + "milestone_approved_at": phase.get("milestone_approved_at"), + } + + if args.dry_run: + print(f" [dry-run] JSONL 레코드: {compact_json(json_record)}") + else: + with open(archive_file, "a", encoding="utf-8") as f: + f.write(compact_json(json_record) + "\n") + print(f" ✅ Phase {phase_num} → {archive_file} 에 append") + + # Replace phase with compressed stub + summary = f"tasks:{task_count} | files:{','.join(files_changed) or '없음'}" + archive_ref = f"harnish-progress-archive.jsonl#phase={phase_num}" + + new_phases = [] + for p in data["done"]["phases"]: + if not p.get("compressed") and p.get("phase") == phase_num: + p = { + "phase": p["phase"], + "title": p.get("title", ""), + "compressed": True, + "compressed_summary": summary, + "archive_ref": archive_ref, + } + new_phases.append(p) + data["done"]["phases"] = new_phases + + if args.dry_run: + print("[dry-run] 실제 변경 없음") + return 0 + + # Atomic write + with tempfile.NamedTemporaryFile( + dir=path.parent, delete=False, suffix=".tmp", mode="w", encoding="utf-8" + ) as f: + tmp = Path(f.name) + f.write(json.dumps(data, ensure_ascii=False, indent=2)) + tmp.replace(path) + + archive_count = 0 + if archive_file.exists(): + with open(archive_file, "r", encoding="utf-8") as f: + archive_count = sum(1 for line in f if line.strip()) + + print("") + print("🗜 압축 완료") + print(f" 아카이브: {archive_file}") + print(f" 백업: {path}.backup") + print(f" 누적 레코드: {archive_count}개 Phase") + return 0 + + +# ── progress-report ─────────────────────────────────────────────────────────── + +def _cmd_report(args) -> int: + path = _resolve_progress(args.progress_file) + + if not path.exists(): + sys.stderr.write(f"오류: harnish-current-work.json 없음: {path}\n") + return 1 + + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + sys.stderr.write(f"오류: 유효한 JSON이 아닙니다: {path}\n") + return 1 + + meta = data.get("metadata", {}) + status_obj = meta.get("status") or {} + + print("# PROGRESS — 자동 갱신 진행 상태") + print("") + print("## 메타데이터") + print(f"- **PRD**: {meta.get('prd', '')}") + print(f"- **시작**: {meta.get('started_at', '')}") + print(f"- **마지막 세션**: {meta.get('last_session', '')}") + emoji = status_obj.get("emoji", "") + phase = status_obj.get("phase", "") + task = status_obj.get("task", "") + label = status_obj.get("label", "") + print(f"- **현재 상태**: {emoji} Phase {phase} / Task {task} {label}") + print("") + print("---") + print("") + + # Done + print("## ✅ 완료 (Done)") + print("") + done_phases = data.get("done", {}).get("phases", []) + if not done_phases: + print("(없음)") + else: + for p in done_phases: + if p.get("compressed"): + print(f"### Phase {p.get('phase')}: {p.get('title')} ✅ [압축됨]") + print(f"- {p.get('compressed_summary', '')}") + print(f"- archive: {p.get('archive_ref', '')}") + print("") + else: + print(f"### Phase {p.get('phase')}: {p.get('title')}") + print("") + for t in p.get("tasks", []): + fc = ", ".join(t.get("files_changed") or []) + print(f"- [x] Task {t.get('id')}: {t.get('title')}") + print(f" - **결과**: {t.get('result') or '미기록'}") + print(f" - **변경 파일**: {fc}") + print(f" - **검증**: {t.get('verification') or '미기록'}") + print(f" - **소요**: {t.get('duration') or '미기록'}") + print("") + print("") + print("---") + print("") + + # Doing + print("## 🔨 진행 중 (Doing)") + print("") + doing_task = data.get("doing", {}).get("task") + if doing_task is None: + print("(없음)") + else: + ctx = doing_task.get("context") or {} + print(f"### Task {doing_task.get('id')}: {doing_task.get('title')}") + print("") + print(f"- **시작**: {doing_task.get('started_at', '')}") + print(f"- **현재**: {doing_task.get('current') or '미설정'}") + print(f"- **마지막 액션**: {doing_task.get('last_action') or '미설정'}") + print(f"- **다음 액션**: {doing_task.get('next_action') or '미설정'}") + print(f"- **블로커**: {doing_task.get('blocker') or '없음'}") + print(f"- **시도 횟수**: {doing_task.get('retry_count', 0)}") + print("") + print("#### 태스크 컨텍스트") + print(f"- **가이드**: {ctx.get('guide') or '미설정'}") + print(f"- **scope**: {ctx.get('scope') or '미설정'}") + print(f"- **참조 PRD**: {ctx.get('prd_reference') or '미설정'}") + print("") + print("---") + print("") + + # Todo + print("## 📋 예정 (Todo)") + print("") + todo_phases = data.get("todo", {}).get("phases", []) + if not todo_phases: + print("(없음)") + else: + for p in todo_phases: + print(f"### Phase {p.get('phase')}: {p.get('title')}") + print("") + for t in p.get("tasks", []): + dep = t.get("depends_on") or [] + suffix = f" (← Task {', '.join(dep)} 필요)" if dep else "" + print(f"- [ ] Task {t.get('id')}: {t.get('title')}{suffix}") + print("") + print("") + print("---") + print("") + + # Issues + print("## ⚠️ 이슈 · 결정 로그") + print("") + issues = data.get("issues", []) + print("| 시점 | 태스크 | 내용 | 결정/해결 |") + print("|------|--------|------|----------|") + if not issues: + print("| (없음) | | | |") + else: + for i in issues: + print(f"| {i.get('timestamp','')} | {i.get('task','')} | {i.get('description','')} | {i.get('resolution') or '미결'} |") + print("") + print("---") + print("") + + # Violations + print("## 🔴 금지사항 위반 기록") + print("") + print("| 시점 | 태스크 | 위반 내용 | 사용자 판단 |") + print("|------|--------|----------|-----------|") + violations = data.get("violations", []) + if not violations: + print("| (없음) | | | |") + else: + for v in violations: + print(f"| {v.get('timestamp','')} | {v.get('task','')} | {v.get('violation','')} | {v.get('user_decision') or '미결'} |") + print("") + print("---") + print("") + + # Stats + print("## 📊 요약 통계") + print("") + stats = data.get("stats") or {} + print(f"- 전체 페이즈: {stats.get('total_phases', 0)}개") + print(f"- 완료 페이즈: {stats.get('completed_phases', 0)}개") + print(f"- 전체 태스크: {stats.get('total_tasks', 0)}개") + print(f"- 완료 태스크: {stats.get('completed_tasks', 0)}개") + print(f"- 이슈 발생: {stats.get('issues_count', 0)}건") + print(f"- 금지사항 위반: {stats.get('violations_count', 0)}건") + + return 0 + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _resolve_progress(progress_file: "str | None") -> Path: + if progress_file: + return Path(progress_file) + return resolve_progress_file() diff --git a/scripts/harnish_py/promote.py b/scripts/harnish_py/promote.py new file mode 100644 index 0000000..a7c6652 --- /dev/null +++ b/scripts/harnish_py/promote.py @@ -0,0 +1,166 @@ +"""promote-pending — deduplicate /tmp pending JSONL and promote to asset store.""" +import hashlib +import json +import os +from datetime import datetime, timezone +from pathlib import Path +from .asset import TYPE_EXTRAS +from .common import resolve_base_dir, slugify +from .init import init_assets +from .io import jsonl_read, jsonl_rewrite, compact_json + + +def register(sub): + p = sub.add_parser("promote-pending", help="promote pending events to asset store") + p.add_argument("--session", default="") + p.add_argument("--base-dir", dest="base_dir", default=None) + p.add_argument("--dry-run", action="store_true", default=False) + p.set_defaults(func=_cmd_promote) + + +def _cmd_promote(args) -> int: + session = args.session + if not session: + session = os.environ.get("CLAUDE_SESSION_ID") or _pid_hash() + result = promote_pending(session, args.base_dir, dry_run=args.dry_run) + print(compact_json(result)) + return 0 + + +def promote_pending(session: str, base_dir: "str | None", dry_run: bool = False) -> dict: + base = resolve_base_dir(base_dir) + pending_file = Path(f"/tmp/harnish-pending-{session}.jsonl") + + if not pending_file.exists(): + return {"status": "no_pending", "promoted": 0, "deduplicated": 0, "skipped": 0} + + if pending_file.stat().st_size == 0: + return {"status": "empty", "promoted": 0, "deduplicated": 0, "skipped": 0} + + # Load all pending records + records = [] + with open(pending_file, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + records.append(json.loads(line)) + except json.JSONDecodeError: + pass + + total_count = len(records) + + # Deduplicate: key = tool + first non-empty line of output (truncated 50) + seen: dict[str, dict] = {} + counts: dict[str, int] = {} + for r in records: + tool = r.get("tool", "") + output = r.get("output", "") + first_line = next( + (ln.strip() for ln in output.split("\n") if ln.strip()), "" + )[:50] + key = tool + "|" + first_line + if key not in seen: + seen[key] = r + counts[key] = counts.get(key, 0) + 1 + + unique = [ + { + "tool": r.get("tool", ""), + "output": r.get("output", ""), + "session": r.get("session", ""), + "date": r.get("date", ""), + "occurrences": counts[k], + } + for k, r in seen.items() + ] + + unique_count = len(unique) + dedup_count = total_count - unique_count + + if unique_count == 0: + return {"status": "empty", "promoted": 0, "deduplicated": 0, "skipped": 0} + + if dry_run: + return { + "status": "dry_run", + "promoted": unique_count, + "deduplicated": dedup_count, + "candidates": unique, + } + + # Ensure .harnish/ exists + if not base.is_dir(): + init_assets(base_dir=str(base), quiet=True) + + asset_file = base / "harnish-assets.jsonl" + + # Load existing records once — build slug set for dedup + existing_records = list(jsonl_read(asset_file)) + existing_slugs: set[str] = {r.get("slug") for r in existing_records} + + short_session = session[:8] + now_utc = datetime.now(timezone.utc) + date_str = now_utc.strftime("%Y-%m-%d") + iso_ts = now_utc.strftime("%Y-%m-%dT%H:%M:%SZ") + + new_records = [] + promoted = 0 + skipped = 0 + + for entry in unique: + output = entry.get("output", "") + if not output: + skipped += 1 + continue + + first_line = next( + (ln.strip() for ln in output.split("\n") if ln.strip()), "" + ) + if not first_line: + skipped += 1 + continue + + title = first_line[:60] + tool = entry.get("tool", "") + occurrences = entry.get("occurrences", 1) + tag_list = ["auto", f"tool:{tool}", f"session:{short_session}"] + context = f"auto-promoted from pending (occurrences: {occurrences})" + + # Slug dedup against existing + already-allocated in this batch + slug = slugify(title) + all_slugs = existing_slugs | {r["slug"] for r in new_records} + if slug in all_slugs: + base_slug = slug + counter = 2 + while slug in all_slugs: + slug = f"{base_slug}-{counter}" + counter += 1 + + record: dict = { + "type": "failure", + "slug": slug, + "title": title, + "tags": tag_list, + "date": date_str, + "scope": "project", + "body": output, + "context": context, + "session": session, + "schema_version": "0.0.2", + "last_accessed_at": iso_ts, + "access_count": 0, + } + record.update(TYPE_EXTRAS.get("failure", {})) + new_records.append(record) + promoted += 1 + + if new_records: + jsonl_rewrite(asset_file, existing_records + new_records) + + return {"status": "promoted", "promoted": promoted, "deduplicated": dedup_count, "skipped": skipped} + + +def _pid_hash() -> str: + return hashlib.md5(str(os.getpid()).encode()).hexdigest()[:8] diff --git a/scripts/harnish_py/purge.py b/scripts/harnish_py/purge.py new file mode 100644 index 0000000..494d1ed --- /dev/null +++ b/scripts/harnish_py/purge.py @@ -0,0 +1,91 @@ +"""purge-assets — TTL-based asset purge (dry-run by default).""" +import sys +import time +from datetime import date, datetime +from pathlib import Path +from .common import resolve_base_dir, resolve_asset_file +from .io import jsonl_read, jsonl_rewrite, compact_json + +# TTL policy (mirrors purge-assets.sh hardcoded defaults) +TTL_DAYS: dict[str, int] = { + "decision": 365, + "failure": 90, + "snippet": 180, + "pattern": -1, # never + "guardrail": -1, # never +} +SAFETY_WINDOW_HOURS = 24 +SAFETY_SECS = SAFETY_WINDOW_HOURS * 3600 + + +def register(sub): + p = sub.add_parser("purge-assets", help="TTL-based asset purge (dry-run default)") + p.add_argument("--execute", action="store_true", default=False) + p.add_argument("--base-dir", dest="base_dir", default=None) + p.set_defaults(func=_cmd_purge) + + +def _cmd_purge(args) -> int: + base = resolve_base_dir(args.base_dir) + asset_file = base / "harnish-assets.jsonl" + archive_file = base / "harnish-assets-archive.jsonl" + + if not asset_file.exists(): + print(compact_json({"status": "no-op", "reason": "asset file absent"})) + return 0 + + now_epoch = time.time() + candidates = [] + survivors = [] + + for r in jsonl_read(asset_file): + if _is_purge_candidate(r, now_epoch): + candidates.append(r) + else: + survivors.append(r) + + if not args.execute: + print(compact_json({"status": "dry_run", "candidates": candidates, + "count": len(candidates)})) + return 0 + + if not candidates: + print(compact_json({"status": "no_candidates", "purged": 0})) + return 0 + + # Append candidates to archive + with open(archive_file, "a", encoding="utf-8") as f: + for c in candidates: + f.write(compact_json(c) + "\n") + + jsonl_rewrite(asset_file, survivors) + + print(compact_json({"status": "purged", "purged": len(candidates), + "archive": str(archive_file)})) + return 0 + + +def _is_purge_candidate(r: dict, now_epoch: float) -> bool: + type_ = r.get("type", "") + ttl = TTL_DAYS.get(type_, 180) + if ttl < 0: + return False + + date_str = r.get("date", "1970-01-01") + try: + d = datetime.strptime(date_str, "%Y-%m-%d") + created_epoch = d.timestamp() + except ValueError: + created_epoch = 0 + + age = now_epoch - created_epoch + if age <= ttl * 86400: + return False + if age <= SAFETY_SECS: + return False + + # Decision: also require access_count >= 1 to keep + if type_ == "decision" and r.get("access_count", 0) < 1: + return True + + return type_ != "decision" diff --git a/scripts/harnish_py/quality.py b/scripts/harnish_py/quality.py new file mode 100644 index 0000000..46aab9d --- /dev/null +++ b/scripts/harnish_py/quality.py @@ -0,0 +1,66 @@ +"""quality-gate — validate required field completeness of asset records.""" +import json +import sys +from .common import resolve_base_dir, resolve_asset_file +from .io import jsonl_read, compact_json + + +def register(sub): + p = sub.add_parser("quality-gate", help="check asset field completeness") + p.add_argument("--base-dir", dest="base_dir", default=None) + p.add_argument("--format", dest="fmt", default="text", + choices=["text", "json"]) + p.set_defaults(func=_cmd_quality) + + +def _cmd_quality(args) -> int: + base = resolve_base_dir(args.base_dir) + asset_file = base / "harnish-assets.jsonl" + + if not asset_file.exists() or asset_file.stat().st_size == 0: + if args.fmt == "json": + print(compact_json({"status": "empty", "issues": []})) + else: + print("자산 없음") + return 0 + + issues = [] + for r in jsonl_read(asset_file): + if r.get("compressed"): + continue + record_issues = [] + if not r.get("type"): + record_issues.append("type 누락") + if not r.get("slug"): + record_issues.append("slug 누락") + if not r.get("title"): + record_issues.append("title 누락") + if not r.get("tags"): + record_issues.append("tags 비어있음") + if not r.get("body"): + record_issues.append("body 비어있음") + if not r.get("context"): + record_issues.append("context 비어있음") + if record_issues: + n = len(record_issues) + quality = "poor" if n > 2 else "fair" + issues.append({ + "slug": r.get("slug"), + "title": r.get("title"), + "quality": quality, + "issues": record_issues, + }) + + if args.fmt == "json": + print(compact_json({"status": "checked", "issue_count": len(issues), + "issues": issues})) + else: + if not issues: + print("품질 게이트 PASS — 모든 자산 완성도 양호") + else: + print(f"품질 게이트: {len(issues)}건 보완 필요") + for issue in issues: + slug = issue.get("slug") or issue.get("title") + issue_str = ", ".join(issue["issues"]) + print(f" [{issue['quality']}] {slug} — {issue_str}") + return 0 diff --git a/scripts/harnish_py/query.py b/scripts/harnish_py/query.py new file mode 100644 index 0000000..e237ad7 --- /dev/null +++ b/scripts/harnish_py/query.py @@ -0,0 +1,119 @@ +"""query-assets — search and format asset records.""" +import sys +from datetime import datetime, timezone +from pathlib import Path +from .common import resolve_base_dir, resolve_asset_file +from .io import jsonl_read, jsonl_rewrite, compact_json + + +def register(sub): + p = sub.add_parser("query-assets", help="search JSONL asset store") + p.add_argument("--tags", required=True) + p.add_argument("--types", default="") + p.add_argument("--format", dest="fmt", default="json", + choices=["json", "text", "inject"]) + p.add_argument("--limit", type=int, default=5) + p.add_argument("--base-dir", dest="base_dir", default=None) + p.set_defaults(func=_cmd_query) + + +def _cmd_query(args) -> int: + return query_assets( + tags=args.tags, + types=args.types, + fmt=args.fmt, + limit=args.limit, + base_dir=args.base_dir, + ) + + +def query_assets(tags: str, types: str = "", fmt: str = "json", + limit: int = 5, base_dir: "str | None" = None) -> int: + base = resolve_base_dir(base_dir) + asset_file = base / "harnish-assets.jsonl" + + query_tags = [t.strip() for t in tags.split(",") if t.strip()] + query_types = [t.strip() for t in types.split(",") if t.strip()] if types else [] + + def empty_result(): + tag_list = query_tags + if fmt == "json": + out = {"query": {"tags": tag_list, "types": [], "limit": limit}, + "results": [], "count": 0} + print(compact_json(out)) + elif fmt == "text": + print("(검색 결과 없음)") + else: # inject + print("### 관련 자산 (asset-recorder)\n") + print("(관련 자산 없음)") + return 0 + + if not asset_file.exists() or asset_file.stat().st_size == 0: + return empty_result() + + # Filter records + results = [] + for record in jsonl_read(asset_file): + if record.get("compressed") is True: + continue + if query_types and record.get("type") not in query_types: + continue + rec_tags = record.get("tags", []) + if any(qt in rec_tags for qt in query_tags): + results.append(record) + if len(results) >= limit: + break + + if not results: + return empty_result() + + # Write-back: update access_count + last_accessed_at for matched records + matched_slugs = {r["slug"] for r in results} + now_utc = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + updated_records = [] + for record in jsonl_read(asset_file): + if record.get("slug") in matched_slugs: + record = dict(record) + record["last_accessed_at"] = now_utc + record["access_count"] = record.get("access_count", 0) + 1 + updated_records.append(record) + + jsonl_rewrite(asset_file, updated_records) + + # Output + if fmt == "json": + out = { + "query": {"tags": query_tags, "types": query_types, "limit": limit}, + "results": results, + "count": len(results), + } + print(compact_json(out)) + + elif fmt == "text": + for r in results: + body_preview = r.get("body", "")[:50] + tag_str = ",".join(r.get("tags", [])) + print(f"[{r.get('type')}] {r.get('title')} ({r.get('date')}) — {body_preview}") + print(f" tags: {tag_str} | scope: {r.get('scope')}\n") + + else: # inject + print("### 관련 자산 (asset-recorder)\n") + for r in results: + type_ = r.get("type", "") + hdr = "[" + type_ + if r.get("level"): + hdr += f"/{r['level']}" + if r.get("confidence"): + hdr += f"/{r['confidence']}" + if r.get("stability") is not None: + hdr += f"/s{r['stability']}" + hdr += "]" + body_preview = r.get("body", "")[:120] + ctx = r.get("context") or "(none)" + line = f"- **{hdr} {r.get('title')}**: {body_preview}" + line += f"\n - context: {ctx}" + if r.get("resolved") is not None: + line += f" | resolved: {r['resolved']}" + print(line) + + return 0 diff --git a/scripts/harnish_py/record.py b/scripts/harnish_py/record.py new file mode 100644 index 0000000..040cbe4 --- /dev/null +++ b/scripts/harnish_py/record.py @@ -0,0 +1,138 @@ +"""record-asset — write an asset record to JSONL store.""" +import json +import sys +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional +from .asset import TYPE_EXTRAS, VALID_TYPES, _append_record +from .common import resolve_base_dir, resolve_asset_file, slugify +from .init import init_assets +from .io import jsonl_read, compact_json + + +def register(sub): + p = sub.add_parser("record-asset", help="record an asset to the JSONL store") + p.add_argument("--type", dest="type_", default="") + p.add_argument("--tags", default="") + p.add_argument("--context", default="") + p.add_argument("--title", default="") + p.add_argument("--body", default="") + p.add_argument("--content", default="") # alias for --body + p.add_argument("--body-file", dest="body_file", default="") + p.add_argument("--session-id", dest="session_id", default="manual") + p.add_argument("--scope", default="generic") + p.add_argument("--base-dir", dest="base_dir", default=None) + p.add_argument("--stdin", action="store_true", default=False) + p.set_defaults(func=_cmd_record) + + +def _cmd_record(args) -> int: + type_ = args.type_ + tags = args.tags + context = args.context + title = args.title + body = args.body or args.content + body_file = args.body_file + session_id = args.session_id + scope = args.scope + base_dir = args.base_dir + + if args.stdin: + raw = sys.stdin.read() + try: + data = json.loads(raw) + except json.JSONDecodeError: + sys.stderr.write('{"status":"error","reason":"stdin에 유효한 JSON이 아님"}\n') + return 1 + type_ = data.get("type", "") + tag_list = data.get("tags", []) + tags = ",".join(tag_list) if isinstance(tag_list, list) else str(tag_list) + context = data.get("context", "") + title = data.get("title", "") + body = data.get("body") or data.get("content") or "" + session_id = data.get("session_id", "stdin") + scope = data.get("scope", "generic") + + if not type_ or not title: + sys.stderr.write('{"status":"error","reason":"--type과 --title은 필수"}\n') + return 1 + + if type_ not in VALID_TYPES: + print(compact_json({"status": "error", "reason": f"unknown type: {type_}"})) + return 1 + + base = resolve_base_dir(base_dir) + if not base.is_dir(): + init_assets(base_dir=str(base), quiet=True) + + asset_file = base / "harnish-assets.jsonl" + + # body from file + body_content = body + if body_file and Path(body_file).is_file(): + body_content = Path(body_file).read_text(encoding="utf-8") + + # slug dedup + slug = slugify(title) + existing_slugs = {r.get("slug") for r in jsonl_read(asset_file)} + if slug in existing_slugs: + base_slug = slug + counter = 2 + while slug in existing_slugs: + slug = f"{base_slug}-{counter}" + counter += 1 + + # tag list + tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else [] + + # timestamps + now_utc = datetime.now(timezone.utc) + date_str = now_utc.strftime("%Y-%m-%d") + iso_ts = now_utc.strftime("%Y-%m-%dT%H:%M:%SZ") + + # build record + record: dict = { + "type": type_, + "slug": slug, + "title": title, + "tags": tag_list, + "date": date_str, + "scope": scope, + "body": body_content, + "context": context, + "session": session_id, + "schema_version": "0.0.2", + "last_accessed_at": iso_ts, + "access_count": 0, + } + record.update(TYPE_EXTRAS.get(type_, {})) + + _append_record(asset_file, record) + + # RCA quality check + warnings = [] + if not context: + warnings.append("context가 비어있습니다") + if not body_content: + warnings.append("body가 비어있습니다") + if not tag_list: + warnings.append("tags가 비어있습니다") + + if len(warnings) > 2: + quality = "poor" + elif warnings: + quality = "fair" + else: + quality = "good" + + result = { + "status": "recorded", + "type": type_, + "slug": slug, + "tags": tag_list, + "alerts": [], + "rca": {"warnings": warnings, "quality": quality}, + } + print(compact_json(result)) + return 0 diff --git a/scripts/harnish_py/skillify.py b/scripts/harnish_py/skillify.py new file mode 100644 index 0000000..b84cff5 --- /dev/null +++ b/scripts/harnish_py/skillify.py @@ -0,0 +1,157 @@ +"""skillify — generate SKILL.md scaffold from asset store.""" +import json +import re +import sys +from collections import Counter +from datetime import datetime +from pathlib import Path +from .common import resolve_base_dir, resolve_asset_file +from .io import jsonl_read, compact_json + + +def register(sub): + p = sub.add_parser("skillify", help="generate SKILL.md scaffold from assets") + p.add_argument("--tag", required=True) + p.add_argument("--skill-name", dest="skill_name", required=True) + p.add_argument("--output-dir", dest="output_dir", default="skills") + p.add_argument("--base-dir", dest="base_dir", default=None) + p.set_defaults(func=_cmd_skillify) + + +def _cmd_skillify(args) -> int: + base = resolve_base_dir(args.base_dir) + asset_file = base / "harnish-assets.jsonl" + + if not asset_file.exists(): + sys.stderr.write(f"오류: {asset_file} 없음\n") + return 1 + + assets = [ + r for r in jsonl_read(asset_file) + if args.tag in r.get("tags", []) and not r.get("compressed") + ] + count = len(assets) + if count == 0: + sys.stderr.write(f"태그 '{args.tag}'에 해당하는 자산이 없습니다\n") + return 1 + + # Type counts + def type_count(t): + return sum(1 for a in assets if a.get("type") == t) + + n_failure = type_count("failure") + n_pattern = type_count("pattern") + n_guardrail = type_count("guardrail") + n_decision = type_count("decision") + n_snippet = type_count("snippet") + + # Trigger candidates (title token frequency) + word_counts: Counter = Counter() + for a in assets: + title = a.get("title", "").lower() + for word in re.findall(r"[a-z][a-z0-9]{2,}", title): + word_counts[word] += 1 + top_words = [w for w, _ in word_counts.most_common(5)] + trigger_candidates = ",".join(top_words) if top_words else "" + + # Directories + skill_dir = Path(args.output_dir) / args.skill_name + refs_dir = skill_dir / "references" + refs_dir.mkdir(parents=True, exist_ok=True) + + # Save source assets + with open(refs_dir / "source-assets.jsonl", "w", encoding="utf-8") as f: + for a in assets: + f.write(compact_json(a) + "\n") + + # Triggers string + tag = args.tag + base_triggers = ( + f'"{tag}", "{tag} 패턴", "{tag} 가이드", ' + f'"apply {tag}", "use {tag}"' + ) + if trigger_candidates: + extra = ", ".join(f'"{w}"' for w in top_words) + trigger_str = f"{base_triggers}, {extra}" + else: + trigger_str = base_triggers + + now_date = datetime.now().strftime("%Y-%m-%d") + + # SKILL.md frontmatter + header + skill_md = skill_dir / "SKILL.md" + with open(skill_md, "w", encoding="utf-8") as f: + f.write(f"""--- +name: {args.skill_name} +version: 0.0.1 +description: > + {tag} 관련 축적 경험 기반 스킬. {count}건 자산 (failure:{n_failure}, pattern:{n_pattern}, guardrail:{n_guardrail}, decision:{n_decision}, snippet:{n_snippet})에서 자동 생성. + Triggers: {trigger_str}. +--- + +# {args.skill_name} + +> 자동 생성된 스킬 초안 — §1 가이드라인을 LLM이 finalize 필요. +> 원본 자산은 `references/source-assets.jsonl`에 보존됨. + +## 1. 가이드라인 (LLM finalize) + +> **TODO**: `references/source-assets.jsonl`의 자산을 분석하여 1-3개 가이드라인으로 요약하세요. +> 각 가이드라인은 1-3줄로, "언제 적용 / 무엇을 할 것 / 무엇을 피할 것" 형태로. +> 마치면 이 섹션 헤더의 "(LLM finalize)" 마커를 제거. + +## 2. 원본 자산 ({count}건) + +""") + + def emit_section(title_str, type_key, n): + if n == 0: + return + f.write(f"### {title_str} ({n})\n\n") + for a in assets: + if a.get("type") != type_key: + continue + body_preview = a.get("body", "")[:200] + ctx = a.get("context") or "(none)" + line = f"- **{a.get('title')}** — {body_preview}\n" + line += f" - context: {ctx}" + if a.get("level"): + line += f"\n - level: {a['level']}" + if a.get("confidence"): + line += f"\n - confidence: {a['confidence']}" + if a.get("stability") is not None: + line += f"\n - stability: {a['stability']}" + if a.get("resolved") is not None: + line += f"\n - resolved: {a['resolved']}" + f.write(line + "\n") + f.write("\n") + + emit_section("Failures", "failure", n_failure) + emit_section("Patterns", "pattern", n_pattern) + emit_section("Guardrails", "guardrail", n_guardrail) + emit_section("Decisions", "decision", n_decision) + emit_section("Snippets", "snippet", n_snippet) + + f.write(f"""## 3. 메타데이터 + +- 생성일: {now_date} +- 원본 태그: `{tag}` +- 자산 수: {count} (failure:{n_failure} | pattern:{n_pattern} | guardrail:{n_guardrail} | decision:{n_decision} | snippet:{n_snippet}) +- 원본 보존: `references/source-assets.jsonl` +- skillify_version: 0.1.0 +""") + + result = { + "status": "generated", + "skill_dir": str(skill_dir), + "asset_count": count, + "breakdown": { + "failure": n_failure, + "pattern": n_pattern, + "guardrail": n_guardrail, + "decision": n_decision, + "snippet": n_snippet, + }, + } + print(compact_json(result)) + return 0 diff --git a/scripts/harnish_py/thresholds.py b/scripts/harnish_py/thresholds.py new file mode 100644 index 0000000..d61f1ed --- /dev/null +++ b/scripts/harnish_py/thresholds.py @@ -0,0 +1,49 @@ +"""check-thresholds — tag frequency report with compression warnings.""" +import json +import sys +from collections import Counter +from .common import resolve_base_dir, resolve_asset_file +from .io import jsonl_read, compact_json + + +def register(sub): + p = sub.add_parser("check-thresholds", help="report tag counts with warnings") + p.add_argument("--base-dir", dest="base_dir", default=None) + p.add_argument("--threshold", type=int, default=5) + p.set_defaults(func=_cmd_thresholds) + + +def _cmd_thresholds(args) -> int: + out = check_thresholds_str(base_dir=args.base_dir, threshold=args.threshold) + if out: + print(out) + return 0 + + +def check_thresholds_str(base_dir: "str | None" = None, + threshold: int = 5) -> str: + """Return the threshold report as a string (empty string if no assets).""" + base = resolve_base_dir(base_dir) + asset_file = base / "harnish-assets.jsonl" + + if not asset_file.exists() or asset_file.stat().st_size == 0: + return "자산 없음" + + tag_counts: Counter = Counter() + for r in jsonl_read(asset_file): + if r.get("compressed"): + continue + for tag in r.get("tags", []): + # Replicate jq .tags[] output: JSON string (with quotes) + tag_counts[compact_json(tag)] += 1 + + if not tag_counts: + return "" + + lines = [] + for tag_json, count in sorted(tag_counts.items(), key=lambda x: -x[1]): + if count >= threshold: + lines.append(f"{tag_json}({count}건) ⚠ 압축 권장") + else: + lines.append(f"{tag_json}({count}건)") + return "\n".join(lines) diff --git a/scripts/harnish_py/violations.py b/scripts/harnish_py/violations.py new file mode 100644 index 0000000..fb3d5fe --- /dev/null +++ b/scripts/harnish_py/violations.py @@ -0,0 +1,52 @@ +"""check-violations — report violations and escalations from progress file.""" +import json +import sys +from pathlib import Path +from .common import resolve_progress_file + + +def register(sub): + p = sub.add_parser("check-violations", help="check violations and escalations") + p.add_argument("progress_file", nargs="?", default=None) + p.set_defaults(func=_cmd_violations) + + +def _cmd_violations(args) -> int: + path = Path(args.progress_file) if args.progress_file else resolve_progress_file() + + if not path.exists(): + sys.stderr.write(f"ERROR: {path} not found\n") + return 1 + + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + sys.stderr.write(f"ERROR: invalid JSON: {path}\n") + return 1 + + violations = data.get("violations") or [] + escalations = data.get("escalations") or [] + + print(f"위반 기록: {len(violations)}건") + print(f"에스컬레이션: {len(escalations)}건") + + if violations: + print("") + print("── 위반 내역 ──") + for v in violations: + ts = v.get("timestamp", "") + task = v.get("task", "") + viol = v.get("violation", "") + decision = v.get("user_decision") or "미결" + print(f" {ts} | Task {task} | {viol} | 판단: {decision}") + + if escalations: + print("") + print("── 에스컬레이션 내역 ──") + for e in escalations: + ts = e.get("timestamp", "") + task = e.get("task", "") + blocked = e.get("blocked_at", "") + print(f" {ts} | Task {task} | {blocked}") + + return 0 diff --git a/scripts/init-assets.sh b/scripts/init-assets.sh index 3dbe434..0e1a904 100755 --- a/scripts/init-assets.sh +++ b/scripts/init-assets.sh @@ -1,45 +1,5 @@ #!/usr/bin/env bash -# init-assets.sh — .harnish/ 디렉토리와 RAG/작업 파일을 초기화한다. -# -# Layer: L1 (Storage) -# 의존: common.sh (L1) -# -# 사용법: -# init-assets.sh # 기본 경로 (CWD/.harnish) -# init-assets.sh --base-dir /path/to/.harnish -# init-assets.sh --quiet # 출력 없이 - -set -euo pipefail - +# init-assets.sh — Python migration wrapper (v0.1.0+) +# 실제 구현: scripts/harnish_py/init.py SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -BASE="$(resolve_base_dir)" -QUIET=false - -while [[ $# -gt 0 ]]; do - case $1 in - --base-dir) BASE="$2"; shift 2;; - --quiet) QUIET=true; shift;; - *) shift;; - esac -done - -log() { $QUIET || echo "$*"; } - -mkdir -p "$BASE" - -ASSET_FILE="$BASE/harnish-assets.jsonl" -LEGACY_RAG_FILE="$BASE/harnish-rag.jsonl" -WORK_FILE="$BASE/harnish-current-work.json" - -# 레거시 → 신규 자동 이전 (idempotent: 신규가 이미 있으면 무시) -if [[ -f "$LEGACY_RAG_FILE" ]] && [[ ! -f "$ASSET_FILE" ]]; then - mv "$LEGACY_RAG_FILE" "$ASSET_FILE" - log "ℹ legacy harnish-rag.jsonl → harnish-assets.jsonl 자동 이전" -fi - -[[ -f "$ASSET_FILE" ]] || touch "$ASSET_FILE" -[[ -f "$WORK_FILE" ]] || echo '{}' > "$WORK_FILE" - -log "✓ .harnish/ 초기화 완료 ($BASE)" +PYTHONPATH="$SCRIPT_DIR" exec python3 -m harnish_py init-assets "$@" diff --git a/scripts/localize-asset.sh b/scripts/localize-asset.sh index 590e668..60909bb 100755 --- a/scripts/localize-asset.sh +++ b/scripts/localize-asset.sh @@ -1,50 +1,5 @@ #!/usr/bin/env bash -# localize-asset.sh — 범용(generic) 자산을 프로젝트 맥락으로 구체화 (JSONL 기반) -# -# 사용법: -# localize-asset.sh --slug "docker-build-cache-generic" [--base-dir .harnish] - -set -euo pipefail - +# localize-asset.sh — Python migration wrapper (v0.1.0+) +# 실제 구현: scripts/harnish_py/asset.py SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -BASE="$(resolve_base_dir)" -SLUG="" - -while [[ $# -gt 0 ]]; do - case $1 in - --slug) SLUG="$2"; shift 2;; - --base-dir) BASE="$2"; shift 2;; - *) shift;; - esac -done - -if [[ -z "$SLUG" ]]; then - echo "오류: --slug 필수" >&2 - exit 1 -fi - -ASSET_FILE="$BASE/harnish-assets.jsonl" - -if [[ ! -f "$ASSET_FILE" ]]; then - echo "오류: $ASSET_FILE 없음" >&2 - exit 1 -fi - -ORIGINAL=$(jq -c --arg s "$SLUG" 'select(.slug == $s)' "$ASSET_FILE" 2>/dev/null | head -1) - -if [[ -z "$ORIGINAL" ]]; then - echo "오류: slug '$SLUG' 없음" >&2 - exit 1 -fi - -# scope를 project로 변경한 사본 추가 (atomic write) -LOCALIZED=$(echo "$ORIGINAL" | jq -c '.scope = "project" | .slug = .slug + "-local" | .context = .context + " (로컬화)"') -TMPRAG=$(mktemp "${ASSET_FILE}.XXXXXX") -trap 'rm -f "$TMPRAG"' EXIT -cp "$ASSET_FILE" "$TMPRAG" -echo "$LOCALIZED" >> "$TMPRAG" -mv "$TMPRAG" "$ASSET_FILE" - -echo "{\"status\":\"localized\",\"slug\":\"$(echo "$ORIGINAL" | jq -r '.slug')-local\"}" +PYTHONPATH="$SCRIPT_DIR" exec python3 -m harnish_py localize-asset "$@" diff --git a/scripts/loop-step.sh b/scripts/loop-step.sh index f18c808..8b9e75e 100755 --- a/scripts/loop-step.sh +++ b/scripts/loop-step.sh @@ -1,160 +1,5 @@ #!/usr/bin/env bash -# loop-step.sh — ralph 루프 단일 스텝 상태 리포터 -# 용도: 현재 harnish-current-work.json에서 루프 좌표를 추출하여 저수준 모델에 주입할 컨텍스트를 출력한다 -# 사용법: bash loop-step.sh [harnish-current-work.json 경로] [--format json|text] - -set -euo pipefail - +# loop-step.sh — Python migration wrapper (v0.1.0+) +# 실제 구현: scripts/harnish_py/progress.py SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -PROGRESS_FILE="" -FORMAT="text" - -while [[ $# -gt 0 ]]; do - case "$1" in - --format) FORMAT="$2"; shift 2;; - --*) shift;; # 알 수 없는 플래그 무시 - *) [[ -z "$PROGRESS_FILE" ]] && PROGRESS_FILE="$1"; shift;; - esac -done - -[[ -z "$PROGRESS_FILE" ]] && PROGRESS_FILE="$(resolve_progress_file)" - -# ──────────────────────────────────────── -# 0. 의존성 + 파일 존재 확인 -# ──────────────────────────────────────── -if ! command -v jq &>/dev/null; then - echo "ERROR: jq가 설치되어 있지 않습니다. brew install jq" >&2 - exit 1 -fi - -if [[ ! -f "$PROGRESS_FILE" ]]; then - echo "ERROR: harnish-current-work.json not found at '$PROGRESS_FILE'" >&2 - echo "HINT: Run harnish Mode A (시딩) first to seed tasks." >&2 - exit 1 -fi - -if ! jq empty "$PROGRESS_FILE" 2>/dev/null; then - echo "ERROR: 유효한 JSON이 아닙니다: $PROGRESS_FILE" >&2 - exit 1 -fi - -# ──────────────────────────────────────── -# 1. 좌표 추출 -# ──────────────────────────────────────── -DOING_NULL=$(jq -r 'if .doing.task == null then "true" else "false" end' "$PROGRESS_FILE") -CURRENT_TASK=$(jq -r '.doing.task.id // ""' "$PROGRESS_FILE") -CURRENT_TITLE=$(jq -r '.doing.task.title // ""' "$PROGRESS_FILE") -NEXT_ACTION=$(jq -r '.doing.task.next_action // ""' "$PROGRESS_FILE") -PRD_PATH=$(jq -r '.metadata.prd // ""' "$PROGRESS_FILE") -CURRENT_PHASE=$(jq -r '.metadata.status.phase // ""' "$PROGRESS_FILE") - -# ──────────────────────────────────────── -# 2. Todo / Done 카운트 -# ──────────────────────────────────────── -TODO_COUNT=$(jq '[.todo.phases[].tasks[]] | length' "$PROGRESS_FILE" 2>/dev/null || echo "0") -DONE_COUNT=$(jq '[.done.phases[] | select(.compressed != true) | .tasks[]] | length' "$PROGRESS_FILE" 2>/dev/null || echo "0") - -# ──────────────────────────────────────── -# 3. 상태 판단 -# ──────────────────────────────────────── -if [[ "$DOING_NULL" == "true" ]]; then - STATUS="NO_DOING" -else - STATUS="ACTIVE" -fi - -if [[ "$TODO_COUNT" -eq 0 ]] && [[ "$STATUS" == "NO_DOING" ]]; then - STATUS="ALL_DONE" -fi - -# ──────────────────────────────────────── -# 4. Phase 마일스톤 감지 -# ──────────────────────────────────────── -PHASE_TODO=0 -MILESTONE_REACHED="false" -MILESTONE_PHASE="" - -if [[ -n "$CURRENT_PHASE" ]] && [[ "$CURRENT_PHASE" != "null" ]]; then - PHASE_TODO=$(jq --argjson p "$CURRENT_PHASE" \ - '[.todo.phases[] | select(.phase == $p) | .tasks[]] | length' \ - "$PROGRESS_FILE" 2>/dev/null || echo "0") -fi - -# NO_DOING + phase의 Todo가 0 = 마일스톤 -LAST_DONE_PHASE=$(jq -r '[.done.phases[] | select(.compressed != true)] | last | .phase // ""' "$PROGRESS_FILE" 2>/dev/null || echo "") -if [[ "$STATUS" == "NO_DOING" ]] && [[ -n "$LAST_DONE_PHASE" ]]; then - PHASE_REMAINING=$(jq --argjson p "$LAST_DONE_PHASE" \ - '[.todo.phases[] | select(.phase == $p) | .tasks[]] | length' \ - "$PROGRESS_FILE" 2>/dev/null || echo "0") - if [[ "$PHASE_REMAINING" -eq 0 ]]; then - MILESTONE_REACHED="true" - MILESTONE_PHASE="$LAST_DONE_PHASE" - fi -fi - -# ──────────────────────────────────────── -# 5. 출력 -# ──────────────────────────────────────── -if [[ "$FORMAT" == "json" ]]; then - jq -n \ - --arg status "$STATUS" \ - --arg task "$CURRENT_TASK" \ - --arg title "$CURRENT_TITLE" \ - --arg phase "$CURRENT_PHASE" \ - --arg next "$NEXT_ACTION" \ - --arg prd "$PRD_PATH" \ - --argjson todo "$TODO_COUNT" \ - --argjson phase_todo "$PHASE_TODO" \ - --arg milestone "$MILESTONE_REACHED" \ - --arg milestone_phase "$MILESTONE_PHASE" \ - --argjson done "$DONE_COUNT" \ - '{ - status: $status, - current_task: $task, - current_title: $title, - current_phase: $phase, - next_action: $next, - prd_path: $prd, - todo_remaining: $todo, - phase_todo_remaining: $phase_todo, - phase_milestone: ($milestone == "true"), - milestone_phase: $milestone_phase, - done_count: $done - }' -else - echo "════════════════════════════════════" - echo " ralph 루프 현재 좌표" - echo "════════════════════════════════════" - echo " STATUS : $STATUS" - echo " Phase : ${CURRENT_PHASE:-미설정}" - echo " Task ID : ${CURRENT_TASK:-없음}" - echo " Title : ${CURRENT_TITLE:-없음}" - echo " 다음 액션 : ${NEXT_ACTION:-미설정}" - echo " PRD : ${PRD_PATH:-미설정}" - echo " Phase Todo : ${PHASE_TODO}개 남음" - echo " 전체 Todo : ${TODO_COUNT}개" - echo " 완료 Done : ${DONE_COUNT}개" - if [[ "$MILESTONE_REACHED" == "true" ]]; then - echo " 마일스톤 : Phase $MILESTONE_PHASE 완료!" - fi - echo "════════════════════════════════════" - echo "" - - case "$STATUS" in - ACTIVE) - echo "→ '${NEXT_ACTION:-다음 액션 미설정}'부터 실행을 재개합니다." - ;; - NO_DOING) - if [[ "$MILESTONE_REACHED" == "true" ]]; then - echo "→ Phase $MILESTONE_PHASE 마일스톤 도달. 사용자 승인 대기." - else - echo "→ Doing이 비어있습니다. Todo에서 다음 태스크를 가져옵니다." - fi - ;; - ALL_DONE) - echo "→ 모든 태스크 완료. 최종 보고를 생성합니다." - ;; - esac -fi +PYTHONPATH="$SCRIPT_DIR" exec python3 -m harnish_py loop-step "$@" diff --git a/scripts/migrate.sh b/scripts/migrate.sh index 4fc884f..371ff81 100755 --- a/scripts/migrate.sh +++ b/scripts/migrate.sh @@ -1,76 +1,5 @@ #!/usr/bin/env bash -# migrate.sh — schema migration with backfill -# Layer: L3 (Aggregate) -# Usage: migrate.sh [--base-dir .harnish] [--target 0.0.2] - -set -euo pipefail - +# migrate.sh — Python migration wrapper (v0.1.0+) +# 실제 구현: scripts/harnish_py/migrate.py SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -BASE="$(resolve_base_dir)" -TARGET="0.0.2" - -while [[ $# -gt 0 ]]; do - case $1 in - --base-dir) BASE="$2"; shift 2;; - --target) TARGET="$2"; shift 2;; - *) shift;; - esac -done - -ASSETS="$BASE/harnish-assets.jsonl" -LOG="$BASE/harnish-migration-log.jsonl" - -[[ -f "$ASSETS" ]] || { echo '{"status":"no-op","reason":"asset file absent"}'; exit 0; } -[[ -s "$ASSETS" ]] || { echo '{"status":"no-op","reason":"asset file empty"}'; exit 0; } - -# Backup -NOW_EPOCH=$(date +%s) -BAK="${ASSETS}.bak.${NOW_EPOCH}" -cp "$ASSETS" "$BAK" - -# Migrate: backfill 3 fields if schema_version missing or older -NOW_UTC=$(date -u +"%Y-%m-%dT%H:%M:%SZ") -TMP=$(mktemp "${ASSETS}.XXXXXX") -trap 'rm -f "$TMP"' EXIT - -MIGRATED=0 -SKIPPED=0 -while IFS= read -r line; do - current=$(echo "$line" | jq -r '.schema_version // "0.0.1"') - if [[ "$current" == "0.0.1" ]]; then - # 백필: last_accessed_at = created_at(.date 값), access_count = 0 - echo "$line" | jq -c --arg v "$TARGET" --argjson ac 0 ' - . + { - schema_version: $v, - last_accessed_at: (.last_accessed_at // .date), - access_count: (.access_count // $ac) - }' >> "$TMP" - MIGRATED=$((MIGRATED+1)) - else - echo "$line" >> "$TMP" - SKIPPED=$((SKIPPED+1)) - fi -done < "$ASSETS" - -mv "$TMP" "$ASSETS" - -# Log -jq -n -c \ - --arg ts "$NOW_UTC" \ - --arg from "0.0.1" \ - --arg to "$TARGET" \ - --argjson migrated "$MIGRATED" \ - --argjson skipped "$SKIPPED" \ - --arg backup "$BAK" \ - '{ts:$ts, from:$from, to:$to, migrated:$migrated, skipped:$skipped, backup:$backup}' \ - >> "$LOG" - -# Backup 보존: 최신 3개만 유지, 나머지 삭제 -BAK_FILES=$(ls -t "${ASSETS}".bak.* 2>/dev/null || true) -if [[ -n "$BAK_FILES" ]]; then - echo "$BAK_FILES" | tail -n +4 | xargs rm -f 2>/dev/null || true -fi - -echo "{\"status\":\"migrated\",\"migrated\":$MIGRATED,\"skipped\":$SKIPPED,\"backup\":\"$BAK\"}" +PYTHONPATH="$SCRIPT_DIR" exec python3 -m harnish_py migrate "$@" diff --git a/scripts/progress-report.sh b/scripts/progress-report.sh index 8e8af02..efd80e6 100755 --- a/scripts/progress-report.sh +++ b/scripts/progress-report.sh @@ -1,139 +1,5 @@ #!/usr/bin/env bash -# progress-report.sh — harnish-current-work.json → 사람용 마크다운 렌더링 -# -# 사용법: bash progress-report.sh [harnish-current-work.json 경로] -# 출력: stdout (markdown) - -set -euo pipefail - +# progress-report.sh — Python migration wrapper (v0.1.0+) +# 실제 구현: scripts/harnish_py/progress.py SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -PROGRESS_FILE="${1:-$(resolve_progress_file)}" - -if [[ ! -f "$PROGRESS_FILE" ]]; then - echo "오류: harnish-current-work.json 없음: $PROGRESS_FILE" >&2 - exit 1 -fi - -# ── 메타데이터 ── -echo "# PROGRESS — 자동 갱신 진행 상태" -echo "" -echo "## 메타데이터" -jq -r '"- **PRD**: \(.metadata.prd) -- **시작**: \(.metadata.started_at) -- **마지막 세션**: \(.metadata.last_session) -- **현재 상태**: \(.metadata.status.emoji) Phase \(.metadata.status.phase) / Task \(.metadata.status.task) \(.metadata.status.label)"' "$PROGRESS_FILE" -echo "" -echo "---" -echo "" - -# ── Done ── -echo "## ✅ 완료 (Done)" -echo "" - -PHASE_COUNT=$(jq '.done.phases | length' "$PROGRESS_FILE") -if [[ "$PHASE_COUNT" -eq 0 ]]; then - echo "(없음)" -else - jq -r '.done.phases[] | - if .compressed then - "### Phase \(.phase): \(.title) ✅ [압축됨]\n- \(.compressed_summary)\n- archive: \(.archive_ref)\n" - else - "### Phase \(.phase): \(.title)\n" + - ([.tasks[] | - "- [x] Task \(.id): \(.title)\n - **결과**: \(.result // "미기록")\n - **변경 파일**: \(.files_changed | join(", "))\n - **검증**: \(.verification // "미기록")\n - **소요**: \(.duration // "미기록")" - ] | join("\n")) + "\n" - end' "$PROGRESS_FILE" -fi -echo "" -echo "---" -echo "" - -# ── Doing ── -echo "## 🔨 진행 중 (Doing)" -echo "" - -DOING_NULL=$(jq 'if .doing.task == null then "true" else "false" end' "$PROGRESS_FILE") -if [[ "$DOING_NULL" == '"true"' ]]; then - echo "(없음)" -else - jq -r '.doing.task | - "### Task \(.id): \(.title)\n -- **시작**: \(.started_at) -- **현재**: \(.current // "미설정") -- **마지막 액션**: \(.last_action // "미설정") -- **다음 액션**: \(.next_action // "미설정") -- **블로커**: \(.blocker // "없음") -- **시도 횟수**: \(.retry_count // 0) - -#### 태스크 컨텍스트 -- **가이드**: \(.context.guide // "미설정") -- **scope**: \(.context.scope // "미설정") -- **참조 PRD**: \(.context.prd_reference // "미설정")"' "$PROGRESS_FILE" -fi -echo "" -echo "---" -echo "" - -# ── Todo ── -echo "## 📋 예정 (Todo)" -echo "" - -TODO_PHASES=$(jq '.todo.phases | length' "$PROGRESS_FILE") -if [[ "$TODO_PHASES" -eq 0 ]]; then - echo "(없음)" -else - jq -r '.todo.phases[] | - "### Phase \(.phase): \(.title)\n" + - ([.tasks[] | - "- [ ] Task \(.id): \(.title)" + - (if (.depends_on | length) > 0 then " (← Task \(.depends_on | join(", ")) 필요)" else "" end) - ] | join("\n")) + "\n"' "$PROGRESS_FILE" -fi -echo "" -echo "---" -echo "" - -# ── Issues ── -echo "## ⚠️ 이슈 · 결정 로그" -echo "" -ISSUES=$(jq '.issues | length' "$PROGRESS_FILE") -if [[ "$ISSUES" -eq 0 ]]; then - echo "| 시점 | 태스크 | 내용 | 결정/해결 |" - echo "|------|--------|------|----------|" - echo "| (없음) | | | |" -else - echo "| 시점 | 태스크 | 내용 | 결정/해결 |" - echo "|------|--------|------|----------|" - jq -r '.issues[] | "| \(.timestamp) | \(.task) | \(.description) | \(.resolution // "미결") |"' "$PROGRESS_FILE" -fi -echo "" -echo "---" -echo "" - -# ── Violations ── -echo "## 🔴 금지사항 위반 기록" -echo "" -echo "| 시점 | 태스크 | 위반 내용 | 사용자 판단 |" -echo "|------|--------|----------|-----------|" -VIOLATIONS=$(jq '.violations | length' "$PROGRESS_FILE") -if [[ "$VIOLATIONS" -eq 0 ]]; then - echo "| (없음) | | | |" -else - jq -r '.violations[] | "| \(.timestamp) | \(.task) | \(.violation) | \(.user_decision // "미결") |"' "$PROGRESS_FILE" -fi -echo "" -echo "---" -echo "" - -# ── Stats ── -echo "## 📊 요약 통계" -echo "" -jq -r '.stats | -"- 전체 페이즈: \(.total_phases)개 -- 완료 페이즈: \(.completed_phases)개 -- 전체 태스크: \(.total_tasks)개 -- 완료 태스크: \(.completed_tasks)개 -- 이슈 발생: \(.issues_count)건 -- 금지사항 위반: \(.violations_count)건"' "$PROGRESS_FILE" +PYTHONPATH="$SCRIPT_DIR" exec python3 -m harnish_py progress-report "$@" diff --git a/scripts/promote-pending.sh b/scripts/promote-pending.sh index 09fc291..66c2ffb 100755 --- a/scripts/promote-pending.sh +++ b/scripts/promote-pending.sh @@ -1,150 +1,5 @@ #!/usr/bin/env bash -# promote-pending.sh — /tmp/harnish-pending-*.jsonl을 deduplicate 후 자산으로 자동 등록 -# -# Layer: L4 (Interface) — hook과 record-asset.sh 사이의 promotion 레이어 -# 의존: common.sh, record-asset.sh -# -# 사용법: -# promote-pending.sh --session SESSION_ID [--base-dir .harnish] [--dry-run] -# promote-pending.sh # CLAUDE_SESSION_ID 또는 PID 해시 -# -# 출력 (JSON): -# {"status":"promoted","promoted":N,"deduplicated":M,"skipped":K} -# {"status":"empty"} -# {"status":"no_pending"} - -set -euo pipefail - +# promote-pending.sh — Python migration wrapper (v0.1.0+) +# 실제 구현: scripts/harnish_py/promote.py SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -BASE="$(resolve_base_dir)" -SESSION="" -DRY_RUN=false - -while [[ $# -gt 0 ]]; do - case $1 in - --session) SESSION="$2"; shift 2;; - --base-dir) BASE="$2"; shift 2;; - --dry-run) DRY_RUN=true; shift;; - *) shift;; - esac -done - -# 세션 해시 결정 -if [[ -z "$SESSION" ]]; then - if [[ -n "${CLAUDE_SESSION_ID:-}" ]]; then - SESSION="$CLAUDE_SESSION_ID" - else - SESSION=$(echo "$$" | md5 2>/dev/null | cut -c1-8 || echo "$$" | md5sum 2>/dev/null | cut -c1-8 || echo "unknown") - fi -fi - -PENDING_FILE="/tmp/harnish-pending-${SESSION}.jsonl" - -if [[ ! -f "$PENDING_FILE" ]]; then - echo '{"status":"no_pending","promoted":0,"deduplicated":0,"skipped":0}' - exit 0 -fi - -if [[ ! -s "$PENDING_FILE" ]]; then - echo '{"status":"empty","promoted":0,"deduplicated":0,"skipped":0}' - exit 0 -fi - -# 첫 의미있는 라인을 추출하는 jq filter (헬퍼) -# pending JSONL의 각 라인은 {event, tool, output, session, date} -# dedup key = tool + first non-empty line of output (50자 truncate) - -# Step 1: dedup -# 각 라인을 key로 그룹화하고 첫 번째 항목 + count 보존 -DEDUP_OUT=$(jq -sc ' - map( - . + { - __key: ((.tool // "") + "|" + ( - (.output // "") | split("\n") | map(select(length > 0)) | first // "" | .[0:50] - )) - } - ) - | group_by(.__key) - | map({ - tool: (.[0].tool // ""), - output: (.[0].output // ""), - session: (.[0].session // ""), - date: (.[0].date // ""), - occurrences: length - }) -' "$PENDING_FILE") - -UNIQUE_COUNT=$(echo "$DEDUP_OUT" | jq 'length') -TOTAL_COUNT=$(wc -l < "$PENDING_FILE" | xargs) -DEDUP_COUNT=$((TOTAL_COUNT - UNIQUE_COUNT)) - -if [[ "$UNIQUE_COUNT" -eq 0 ]]; then - echo '{"status":"empty","promoted":0,"deduplicated":0,"skipped":0}' - exit 0 -fi - -# Step 2: dry-run 처리 -if $DRY_RUN; then - jq -n -c \ - --arg status "dry_run" \ - --argjson promoted "$UNIQUE_COUNT" \ - --argjson dedup "$DEDUP_COUNT" \ - --argjson candidates "$DEDUP_OUT" \ - '{status:$status, promoted:$promoted, deduplicated:$dedup, candidates:$candidates}' - exit 0 -fi - -# Step 3: 각 unique entry를 record-asset.sh로 등록 -PROMOTED=0 -SKIPPED=0 -SHORT_SESSION="${SESSION:0:8}" - -# echo "$DEDUP_OUT" | jq -c '.[]'를 줄별 처리 -while IFS= read -r entry; do - [[ -z "$entry" ]] && continue - - TOOL=$(echo "$entry" | jq -r '.tool // ""') - OUTPUT=$(echo "$entry" | jq -r '.output // ""') - OCCURRENCES=$(echo "$entry" | jq -r '.occurrences // 1') - - # 빈 output skip - [[ -z "$OUTPUT" ]] && SKIPPED=$((SKIPPED + 1)) && continue - - # 첫 라인을 title로 - FIRST_LINE=$(echo "$OUTPUT" | grep -m1 '[^[:space:]]' || echo "") - [[ -z "$FIRST_LINE" ]] && SKIPPED=$((SKIPPED + 1)) && continue - - # title은 60자 truncate - TITLE="${FIRST_LINE:0:60}" - - # tags - TAGS="auto,tool:${TOOL},session:${SHORT_SESSION}" - - # context - CONTEXT="auto-promoted from pending (occurrences: ${OCCURRENCES})" - - # record-asset.sh 호출 - if bash "$SCRIPT_DIR/record-asset.sh" \ - --type failure \ - --tags "$TAGS" \ - --title "$TITLE" \ - --body "$OUTPUT" \ - --context "$CONTEXT" \ - --scope project \ - --session-id "$SESSION" \ - --base-dir "$BASE" >/dev/null 2>&1; then - PROMOTED=$((PROMOTED + 1)) - else - SKIPPED=$((SKIPPED + 1)) - fi -done < <(echo "$DEDUP_OUT" | jq -c '.[]') - -# Step 4: 출력 -jq -n -c \ - --arg status "promoted" \ - --argjson promoted "$PROMOTED" \ - --argjson dedup "$DEDUP_COUNT" \ - --argjson skipped "$SKIPPED" \ - '{status:$status, promoted:$promoted, deduplicated:$dedup, skipped:$skipped}' +PYTHONPATH="$SCRIPT_DIR" exec python3 -m harnish_py promote-pending "$@" diff --git a/scripts/purge-assets.sh b/scripts/purge-assets.sh index 4d29561..5dabdf6 100755 --- a/scripts/purge-assets.sh +++ b/scripts/purge-assets.sh @@ -1,81 +1,5 @@ #!/usr/bin/env bash -# purge-assets.sh — TTL 기반 자산 purge (dry-run 기본) -# Layer: L3 (Aggregate) -# Usage: purge-assets.sh [--execute] [--base-dir .harnish] - -set -euo pipefail - +# purge-assets.sh — Python migration wrapper (v0.1.0+) +# 실제 구현: scripts/harnish_py/purge.py SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -BASE="$(resolve_base_dir)" -EXECUTE=false - -while [[ $# -gt 0 ]]; do - case $1 in - --execute) EXECUTE=true; shift;; - --base-dir) BASE="$2"; shift 2;; - *) shift;; - esac -done - -ASSETS="$BASE/harnish-assets.jsonl" -ARCHIVE="$BASE/harnish-assets-archive.jsonl" - -[[ -f "$ASSETS" ]] || { echo '{"status":"no-op","reason":"asset file absent"}'; exit 0; } - -# Hardcoded defaults (retention-policy.md 참조; 향후 파싱 확장 가능) -# ttl_days: decision=365, failure=90, pattern=never, guardrail=never, snippet=180 -# safety_window_hours = 24 -# min_access_count = 1 (decision only) - -NOW_EPOCH=$(date +%s) -SAFETY_SEC=$((24 * 3600)) - -# jq filter: purge 대상 판정 -PURGE_FILTER=' - def ttl_days: - if .type == "decision" then 365 - elif .type == "failure" then 90 - elif .type == "snippet" then 180 - elif .type == "pattern" or .type == "guardrail" then -1 - else 180 end; - def created_epoch: - (.date // "1970-01-01") | strptime("%Y-%m-%d") | mktime; - def is_purge_candidate: - ttl_days as $ttl - | if $ttl < 0 then false - else - (($now_epoch - created_epoch) > ($ttl * 86400)) - and (($now_epoch - created_epoch) > $safety_sec) - and (if .type == "decision" then (.access_count // 0) < 1 else true end) - end; - is_purge_candidate -' - -CANDIDATES=$(jq -c --argjson now_epoch "$NOW_EPOCH" --argjson safety_sec "$SAFETY_SEC" \ - "select($PURGE_FILTER)" "$ASSETS" 2>/dev/null || echo "") -CANDIDATE_COUNT=$(echo "$CANDIDATES" | awk 'NF' | wc -l | xargs) - -if ! $EXECUTE; then - # Dry-run: 출력만 - C_JSON=$(echo "$CANDIDATES" | awk 'NF' | jq -s '.') - jq -n -c --argjson c "$C_JSON" --argjson count "$CANDIDATE_COUNT" \ - '{status:"dry_run",candidates:$c,count:$count}' - exit 0 -fi - -# Execute: 아카이브 + 원본 재작성 -[[ "$CANDIDATE_COUNT" -eq 0 ]] && { echo '{"status":"no_candidates","purged":0}'; exit 0; } - -# Append candidates to archive -echo "$CANDIDATES" >> "$ARCHIVE" - -# Rewrite asset file with non-candidates -TMP=$(mktemp "${ASSETS}.XXXXXX") -trap 'rm -f "$TMP"' EXIT -jq -c --argjson now_epoch "$NOW_EPOCH" --argjson safety_sec "$SAFETY_SEC" \ - "select($PURGE_FILTER | not)" "$ASSETS" > "$TMP" -mv "$TMP" "$ASSETS" - -echo "{\"status\":\"purged\",\"purged\":$CANDIDATE_COUNT,\"archive\":\"$ARCHIVE\"}" +PYTHONPATH="$SCRIPT_DIR" exec python3 -m harnish_py purge-assets "$@" diff --git a/scripts/quality-gate.sh b/scripts/quality-gate.sh index ca99966..8f5929b 100755 --- a/scripts/quality-gate.sh +++ b/scripts/quality-gate.sh @@ -1,55 +1,5 @@ #!/usr/bin/env bash -# quality-gate.sh — JSONL 자산의 필수 필드 완성도 확인 -# -# 사용법: -# quality-gate.sh [--base-dir .harnish] [--format json|text] - -set -euo pipefail - +# quality-gate.sh — Python migration wrapper (v0.1.0+) +# 실제 구현: scripts/harnish_py/quality.py SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -BASE="$(resolve_base_dir)" -FORMAT="text" - -while [[ $# -gt 0 ]]; do - case $1 in - --base-dir) BASE="$2"; shift 2;; - --format) FORMAT="$2"; shift 2;; - *) shift;; - esac -done - -ASSET_FILE="$BASE/harnish-assets.jsonl" - -if [[ ! -f "$ASSET_FILE" ]] || [[ ! -s "$ASSET_FILE" ]]; then - [[ "$FORMAT" == "json" ]] && echo '{"status":"empty","issues":[]}' || echo "자산 없음" - exit 0 -fi - -# 각 레코드의 필수 필드 검증 -ISSUES=$(jq -c 'select(.compressed != true) | - . as $r | - [ - (if (.type | length) == 0 then "type 누락" else empty end), - (if (.slug | length) == 0 then "slug 누락" else empty end), - (if (.title | length) == 0 then "title 누락" else empty end), - (if (.tags | length) == 0 then "tags 비어있음" else empty end), - (if (.body | length) == 0 then "body 비어있음" else empty end), - (if (.context | length) == 0 then "context 비어있음" else empty end) - ] | if length > 0 then {slug: $r.slug, title: $r.title, quality: (if length > 2 then "poor" elif length > 0 then "fair" else "good" end), issues: .} else empty end -' "$ASSET_FILE" 2>/dev/null | jq -s '.' 2>/dev/null || echo "[]") - -ISSUE_COUNT=$(echo "$ISSUES" | jq 'length') - -if [[ "$FORMAT" == "json" ]]; then - jq -n --argjson issues "$ISSUES" --argjson count "$ISSUE_COUNT" \ - '{status: "checked", issue_count: $count, issues: $issues}' -else - if [[ "$ISSUE_COUNT" -eq 0 ]]; then - echo "품질 게이트 PASS — 모든 자산 완성도 양호" - else - echo "품질 게이트: ${ISSUE_COUNT}건 보완 필요" - echo "$ISSUES" | jq -r '.[] | " [\(.quality)] \(.slug // .title) — \(.issues | join(", "))"' - fi -fi +PYTHONPATH="$SCRIPT_DIR" exec python3 -m harnish_py quality-gate "$@" diff --git a/scripts/query-assets.sh b/scripts/query-assets.sh index 9296907..62ceef6 100755 --- a/scripts/query-assets.sh +++ b/scripts/query-assets.sh @@ -1,130 +1,5 @@ #!/usr/bin/env bash -# query-assets.sh — JSONL 자산 검색 -# -# Layer: L2 (Operation) -# 의존: common.sh (L1) -# -# 사용법: -# query-assets.sh --tags "docker,build" [--types "guardrail,pattern"] [--format json|text|inject] [--limit 5] [--base-dir .harnish] - -set -euo pipefail - +# query-assets.sh — Python migration wrapper (v0.1.0+) +# 실제 구현: scripts/harnish_py/query.py SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -TAGS="" TYPES="" FORMAT="json" LIMIT=5 BASE_DIR="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --tags) TAGS="$2"; shift 2;; - --types) TYPES="$2"; shift 2;; - --format) FORMAT="$2"; shift 2;; - --limit) LIMIT="$2"; shift 2;; - --base-dir) BASE_DIR="$2"; shift 2;; - *) echo "알 수 없는 옵션: $1" >&2; exit 1;; - esac -done - -if [[ -z "$TAGS" ]]; then - echo "오류: --tags 필수" >&2 - exit 1 -fi - -[[ -z "$BASE_DIR" ]] && BASE_DIR="$(resolve_base_dir)" - -ASSET_FILE="${BASE_DIR}/harnish-assets.jsonl" - -# --- 빈 결과 처리 --- -empty_result() { - local tag_json - tag_json=$(echo "$TAGS" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | jq -R . | jq -s .) - case "$FORMAT" in - json) echo "{\"query\":{\"tags\":$tag_json,\"types\":[],\"limit\":$LIMIT},\"results\":[],\"count\":0}";; - text) echo "(검색 결과 없음)";; - inject) echo -e "### 관련 자산 (asset-recorder)\n\n(관련 자산 없음)";; - esac - exit 0 -} - -if [[ ! -f "$ASSET_FILE" ]] || [[ ! -s "$ASSET_FILE" ]]; then - empty_result -fi - -# --- 태그/타입 배열 --- -IFS=',' read -ra QUERY_TAGS <<< "$TAGS" -for i in "${!QUERY_TAGS[@]}"; do - QUERY_TAGS[$i]=$(echo "${QUERY_TAGS[$i]}" | xargs) -done - -TAG_JSON=$(printf '%s\n' "${QUERY_TAGS[@]}" | jq -R . | jq -s .) - -TYPE_JSON="[]" -if [[ -n "$TYPES" ]]; then - TYPE_JSON=$(echo "$TYPES" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | jq -R . | jq -s .) -fi - -# --- jq 필터 구성 --- -JQ_FILTER="select(.compressed != true)" - -# 타입 필터 -if [[ -n "$TYPES" ]]; then - JQ_FILTER="${JQ_FILTER} | select([.type] | inside(${TYPE_JSON}))" -fi - -# 태그 매칭 (OR: 하나라도 매칭) -JQ_FILTER="${JQ_FILTER} | select(.tags as \$t | ${TAG_JSON} | any(. as \$q | \$t | any(. == \$q)))" - -# --- 검색 실행 --- -RESULTS=$(jq -c "${JQ_FILTER}" "$ASSET_FILE" 2>/dev/null | head -n "$LIMIT" | jq -s '.' 2>/dev/null || echo "[]") -RESULT_COUNT=$(echo "$RESULTS" | jq 'length') - -if [[ "$RESULT_COUNT" -eq 0 ]]; then - empty_result -fi - -# --- write-back: 매칭 레코드의 access_count 증분 + last_accessed_at 갱신 --- -# 출력 전에 실행 (empty_result 분기 시 skip됨 = OK, 매칭 0이면 갱신 불필요) -NOW_UTC=$(date -u +"%Y-%m-%dT%H:%M:%SZ") -MATCHED_SLUGS=$(echo "$RESULTS" | jq -c '[.[].slug]') -TMP_RAG=$(mktemp "${ASSET_FILE}.XXXXXX") -trap 'rm -f "$TMP_RAG"' EXIT -jq -c --arg now "$NOW_UTC" --argjson slugs "$MATCHED_SLUGS" \ - 'if (.slug as $s | $slugs | any(. == $s)) - then . + {last_accessed_at: $now, access_count: ((.access_count // 0) + 1)} - else . end' "$ASSET_FILE" > "$TMP_RAG" -mv "$TMP_RAG" "$ASSET_FILE" - -# --- 출력 --- -case "$FORMAT" in - json) - jq -n \ - --argjson tags "$TAG_JSON" \ - --argjson types "$TYPE_JSON" \ - --argjson limit "$LIMIT" \ - --argjson results "$RESULTS" \ - --argjson count "$RESULT_COUNT" \ - '{query:{tags:$tags,types:$types,limit:$limit},results:$results,count:$count}' - ;; - text) - echo "$RESULTS" | jq -r '.[] | - "[\(.type)] \(.title) (\(.date)) — \(.body[0:50])" + - "\n tags: \(.tags | join(",")) | scope: \(.scope)\n"' - ;; - inject) - echo "### 관련 자산 (asset-recorder)" - echo "" - echo "$RESULTS" | jq -r '.[] | - # type 헤더 — guardrail은 level, decision은 confidence, pattern은 stability를 머리에 노출 - ( "[\(.type)" + - (if .level then "/\(.level)" else "" end) + - (if .confidence then "/\(.confidence)" else "" end) + - (if .stability then "/s\(.stability)" else "" end) + - "]" ) as $hdr | - "- **\($hdr) \(.title)**: \(.body[0:120])" + - "\n - context: \(.context // "(none)")" + - (if .resolved != null then " | resolved: \(.resolved)" else "" end) - ' - ;; - *) - echo "오류: 알 수 없는 포맷 '$FORMAT'" >&2; exit 1;; -esac +PYTHONPATH="$SCRIPT_DIR" exec python3 -m harnish_py query-assets "$@" diff --git a/scripts/record-asset.sh b/scripts/record-asset.sh index b5680ce..07a87a4 100755 --- a/scripts/record-asset.sh +++ b/scripts/record-asset.sh @@ -1,150 +1,5 @@ #!/usr/bin/env bash -# record-asset.sh — 자산을 JSONL 파일에 1줄로 기록한다. -# -# Layer: L1 (Storage) -# 의존: common.sh (L1) -# -# 사용법: -# record-asset.sh --type pattern --tags "api,retry" --title "exponential-backoff" --body "내용" -# record-asset.sh --type failure --tags "docker,build" --title "cache-miss" --body-file /tmp/detail.md -# echo '{"type":"failure","tags":["api"],"title":"...","body":"..."}' | record-asset.sh --stdin - -set -euo pipefail - +# record-asset.sh — Python migration wrapper (v0.1.0+) +# 실제 구현: scripts/harnish_py/record.py SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -BASE="$(resolve_base_dir)" -DATE=$(date +"%Y-%m-%d") -ISO_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - -# --- 인자 파싱 --- -TYPE="" TAGS="" CONTEXT="" TITLE="" BODY="" BODY_FILE="" -SESSION_ID="manual" SCOPE="generic" STDIN=false - -while [[ $# -gt 0 ]]; do - case $1 in - --type) TYPE="$2"; shift 2;; - --tags) TAGS="$2"; shift 2;; - --context) CONTEXT="$2"; shift 2;; - --title) TITLE="$2"; shift 2;; - --body) BODY="$2"; shift 2;; - --content) BODY="$2"; shift 2;; - --body-file) BODY_FILE="$2"; shift 2;; - --session-id) SESSION_ID="$2"; shift 2;; - --scope) SCOPE="$2"; shift 2;; - --base-dir) BASE="$2"; shift 2;; - --stdin) STDIN=true; shift;; - *) shift;; - esac -done - -if $STDIN; then - INPUT=$(cat) - TYPE=$(echo "$INPUT" | jq -r '.type // empty') - TAGS=$(echo "$INPUT" | jq -r '(.tags // []) | join(",")') - CONTEXT=$(echo "$INPUT" | jq -r '.context // ""') - TITLE=$(echo "$INPUT" | jq -r '.title // ""') - BODY=$(echo "$INPUT" | jq -r '.body // .content // ""') - SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "stdin"') - SCOPE=$(echo "$INPUT" | jq -r '.scope // "generic"') -fi - -if [[ -z "$TYPE" || -z "$TITLE" ]]; then - echo '{"status":"error","reason":"--type과 --title은 필수"}' >&2 - exit 1 -fi - -# type 검증 -case "$TYPE" in - failure|pattern|guardrail|snippet|decision) ;; - *) echo "{\"status\":\"error\",\"reason\":\"unknown type: $TYPE\"}"; exit 1;; -esac - -# --- .harnish/ 초기화 --- -if [[ ! -d "$BASE" ]]; then - bash "$SCRIPT_DIR/init-assets.sh" --base-dir "$BASE" --quiet -fi - -ASSET_FILE="$BASE/harnish-assets.jsonl" - -# --- 본문 --- -BODY_CONTENT="$BODY" -if [[ -n "$BODY_FILE" && -f "$BODY_FILE" ]]; then - BODY_CONTENT=$(cat "$BODY_FILE") -fi - -# --- 슬러그 (중복 방지: 동일 slug 존재 시 -2, -3 ... suffix) --- -SLUG=$(slugify "$TITLE") -if [[ -f "$ASSET_FILE" ]] && [[ -s "$ASSET_FILE" ]]; then - BASE_SLUG="$SLUG" - COUNTER=2 - while jq -e --arg s "$SLUG" 'select(.slug == $s)' "$ASSET_FILE" 2>/dev/null | grep -q .; do - SLUG="${BASE_SLUG}-${COUNTER}" - COUNTER=$((COUNTER + 1)) - done -fi - -# --- 태그 배열 --- -TAG_JSON="[]" -if [[ -n "$TAGS" ]]; then - TAG_JSON=$(echo "$TAGS" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | jq -R . | jq -s .) -fi - -# --- JSON 레코드 구성 --- -RECORD=$(jq -n -c \ - --arg type "$TYPE" \ - --arg slug "$SLUG" \ - --arg title "$TITLE" \ - --argjson tags "$TAG_JSON" \ - --arg date "$DATE" \ - --arg scope "$SCOPE" \ - --arg body "$BODY_CONTENT" \ - --arg context "$CONTEXT" \ - --arg session "$SESSION_ID" \ - --arg iso_ts "$ISO_TIMESTAMP" \ - '{type:$type, slug:$slug, title:$title, tags:$tags, date:$date, scope:$scope, body:$body, context:$context, session:$session, schema_version: "0.0.2", last_accessed_at: $iso_ts, access_count: 0}') - -# 타입별 선택 필드 -case "$TYPE" in - failure) RECORD=$(echo "$RECORD" | jq -c '. + {resolved: true}');; - pattern|snippet) RECORD=$(echo "$RECORD" | jq -c '. + {stability: 1}');; - guardrail) RECORD=$(echo "$RECORD" | jq -c '. + {level: "soft"}');; - decision) RECORD=$(echo "$RECORD" | jq -c '. + {confidence: "medium"}');; -esac - -# --- append (atomic: copy + append + mv) --- -TMPRAG=$(mktemp "${ASSET_FILE}.XXXXXX") -trap 'rm -f "$TMPRAG"' EXIT -cp "$ASSET_FILE" "$TMPRAG" -echo "$RECORD" >> "$TMPRAG" -mv "$TMPRAG" "$ASSET_FILE" - -# --- RCA 검증 --- -RCA_WARNINGS=() -[[ -z "$CONTEXT" ]] && RCA_WARNINGS+=("context가 비어있습니다") -[[ -z "$BODY_CONTENT" ]] && RCA_WARNINGS+=("body가 비어있습니다") -[[ "$TAGS" == "" ]] && RCA_WARNINGS+=("tags가 비어있습니다") - -RCA_QUALITY="good" -if [[ ${#RCA_WARNINGS[@]} -gt 2 ]]; then - RCA_QUALITY="poor" -elif [[ ${#RCA_WARNINGS[@]} -gt 0 ]]; then - RCA_QUALITY="fair" -fi - -if [[ ${#RCA_WARNINGS[@]} -gt 0 ]]; then - RCA_WARN_JSON=$(printf '%s\n' "${RCA_WARNINGS[@]}" | jq -R . | jq -s .) -else - RCA_WARN_JSON="[]" -fi - -# --- 결과 --- -jq -n -c \ - --arg status "recorded" \ - --arg type "$TYPE" \ - --arg slug "$SLUG" \ - --argjson tags "$TAG_JSON" \ - --argjson rca_warnings "$RCA_WARN_JSON" \ - --arg rca_quality "$RCA_QUALITY" \ - '{status:$status, type:$type, slug:$slug, tags:$tags, alerts:[], rca:{warnings:$rca_warnings, quality:$rca_quality}}' +PYTHONPATH="$SCRIPT_DIR" exec python3 -m harnish_py record-asset "$@" diff --git a/scripts/skillify.sh b/scripts/skillify.sh index 12d9682..5d41679 100755 --- a/scripts/skillify.sh +++ b/scripts/skillify.sh @@ -1,164 +1,5 @@ #!/usr/bin/env bash -# skillify.sh — JSONL 자산에서 production-grade SKILL.md scaffold 생성 -# -# v0.0.5: description에 Triggers 자동 생성, body 구조화 (type별 섹션), -# references/source-assets.jsonl로 트레이서빌리티 보존. -# 여전히 LLM이 §1 가이드라인 finalize 필요 (autonomous 아님). -# -# 사용법: -# skillify.sh --tag docker --skill-name docker-patterns [--output-dir skills] [--base-dir .harnish] - -set -euo pipefail - +# skillify.sh — Python migration wrapper (v0.1.0+) +# 실제 구현: scripts/harnish_py/skillify.py SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -BASE="$(resolve_base_dir)" -TAG="" SKILL_NAME="" OUTPUT_DIR="skills" - -while [[ $# -gt 0 ]]; do - case $1 in - --tag) TAG="$2"; shift 2;; - --skill-name) SKILL_NAME="$2"; shift 2;; - --output-dir) OUTPUT_DIR="$2"; shift 2;; - --base-dir) BASE="$2"; shift 2;; - *) shift;; - esac -done - -if [[ -z "$TAG" || -z "$SKILL_NAME" ]]; then - echo "오류: --tag, --skill-name 필수" >&2 - exit 1 -fi - -ASSET_FILE="$BASE/harnish-assets.jsonl" - -if [[ ! -f "$ASSET_FILE" ]]; then - echo "오류: $ASSET_FILE 없음" >&2 - exit 1 -fi - -# 해당 태그의 자산 수집 -ASSETS=$(jq -c --arg t "$TAG" \ - 'select(.tags[] == $t) | select(.compressed != true)' \ - "$ASSET_FILE" 2>/dev/null | jq -s '.' 2>/dev/null || echo "[]") -COUNT=$(echo "$ASSETS" | jq 'length') - -if [[ "$COUNT" -eq 0 ]]; then - echo "태그 '$TAG'에 해당하는 자산이 없습니다" >&2 - exit 1 -fi - -# 타입별 카운트 -N_FAILURE=$(echo "$ASSETS" | jq '[.[] | select(.type == "failure")] | length') -N_PATTERN=$(echo "$ASSETS" | jq '[.[] | select(.type == "pattern")] | length') -N_GUARDRAIL=$(echo "$ASSETS" | jq '[.[] | select(.type == "guardrail")] | length') -N_DECISION=$(echo "$ASSETS" | jq '[.[] | select(.type == "decision")] | length') -N_SNIPPET=$(echo "$ASSETS" | jq '[.[] | select(.type == "snippet")] | length') - -# Triggers 후보 자동 추출 (자산 title의 토큰 빈도 기반) -TRIGGER_CANDIDATES=$(echo "$ASSETS" \ - | jq -r '.[].title' \ - | tr '[:upper:]' '[:lower:]' \ - | tr -c '[:alnum:]\n' ' ' \ - | tr ' ' '\n' \ - | grep -E '^[a-z][a-z0-9]{2,}$' \ - | sort | uniq -c | sort -rn \ - | awk '{print $2}' \ - | head -5 \ - | paste -s -d ',' - || echo "") - -# 디렉토리 생성 -SKILL_DIR="${OUTPUT_DIR}/${SKILL_NAME}" -REFS_DIR="${SKILL_DIR}/references" -mkdir -p "$REFS_DIR" - -# references/source-assets.jsonl — 트레이서빌리티 -echo "$ASSETS" | jq -c '.[]' > "${REFS_DIR}/source-assets.jsonl" - -# Triggers 문자열 (description용) -BASE_TRIGGERS="\"${TAG}\", \"${TAG} 패턴\", \"${TAG} 가이드\", \"apply ${TAG}\", \"use ${TAG}\"" -if [[ -n "$TRIGGER_CANDIDATES" ]]; then - EXTRA=$(echo "$TRIGGER_CANDIDATES" | tr ',' '\n' | sed 's/^/, "/' | sed 's/$/"/' | tr -d '\n') - TRIGGER_STR="${BASE_TRIGGERS}${EXTRA}" -else - TRIGGER_STR="${BASE_TRIGGERS}" -fi - -NOW_DATE=$(date +%Y-%m-%d) - -# SKILL.md frontmatter + 헤더 -cat > "${SKILL_DIR}/SKILL.md" << EOF ---- -name: ${SKILL_NAME} -version: 0.0.1 -description: > - ${TAG} 관련 축적 경험 기반 스킬. ${COUNT}건 자산 (failure:${N_FAILURE}, pattern:${N_PATTERN}, guardrail:${N_GUARDRAIL}, decision:${N_DECISION}, snippet:${N_SNIPPET})에서 자동 생성. - Triggers: ${TRIGGER_STR}. ---- - -# ${SKILL_NAME} - -> 자동 생성된 스킬 초안 — §1 가이드라인을 LLM이 finalize 필요. -> 원본 자산은 \`references/source-assets.jsonl\`에 보존됨. - -## 1. 가이드라인 (LLM finalize) - -> **TODO**: \`references/source-assets.jsonl\`의 자산을 분석하여 1-3개 가이드라인으로 요약하세요. -> 각 가이드라인은 1-3줄로, "언제 적용 / 무엇을 할 것 / 무엇을 피할 것" 형태로. -> 마치면 이 섹션 헤더의 "(LLM finalize)" 마커를 제거. - -## 2. 원본 자산 (${COUNT}건) - -EOF - -# Type별 섹션 (자산 0건 type은 생략) -emit_section() { - local section_title="$1" - local type_key="$2" - local count="$3" - [[ "$count" -eq 0 ]] && return - { - echo "### ${section_title} (${count})" - echo "" - echo "$ASSETS" | jq -r --arg t "$type_key" ' - .[] | select(.type == $t) | - "- **\(.title)** — \(.body[0:200])\n - context: \(.context // "(none)")" + - (if .level then "\n - level: \(.level)" else "" end) + - (if .confidence then "\n - confidence: \(.confidence)" else "" end) + - (if .stability then "\n - stability: \(.stability)" else "" end) + - (if .resolved != null then "\n - resolved: \(.resolved)" else "" end) - ' - echo "" - } >> "${SKILL_DIR}/SKILL.md" -} - -emit_section "Failures" "failure" "$N_FAILURE" -emit_section "Patterns" "pattern" "$N_PATTERN" -emit_section "Guardrails" "guardrail" "$N_GUARDRAIL" -emit_section "Decisions" "decision" "$N_DECISION" -emit_section "Snippets" "snippet" "$N_SNIPPET" - -# 메타데이터 -cat >> "${SKILL_DIR}/SKILL.md" << EOF -## 3. 메타데이터 - -- 생성일: ${NOW_DATE} -- 원본 태그: \`${TAG}\` -- 자산 수: ${COUNT} (failure:${N_FAILURE} | pattern:${N_PATTERN} | guardrail:${N_GUARDRAIL} | decision:${N_DECISION} | snippet:${N_SNIPPET}) -- 원본 보존: \`references/source-assets.jsonl\` -- skillify_version: 0.0.5 -EOF - -# 결과 출력 -jq -n -c \ - --arg status "generated" \ - --arg dir "$SKILL_DIR" \ - --argjson count "$COUNT" \ - --argjson n_failure "$N_FAILURE" \ - --argjson n_pattern "$N_PATTERN" \ - --argjson n_guardrail "$N_GUARDRAIL" \ - --argjson n_decision "$N_DECISION" \ - --argjson n_snippet "$N_SNIPPET" \ - '{status:$status, skill_dir:$dir, asset_count:$count, - breakdown:{failure:$n_failure, pattern:$n_pattern, guardrail:$n_guardrail, decision:$n_decision, snippet:$n_snippet}}' +PYTHONPATH="$SCRIPT_DIR" exec python3 -m harnish_py skillify "$@" diff --git a/scripts/validate-progress.sh b/scripts/validate-progress.sh index d0c64e5..cfc47e8 100755 --- a/scripts/validate-progress.sh +++ b/scripts/validate-progress.sh @@ -1,126 +1,5 @@ #!/usr/bin/env bash -# validate-progress.sh — harnish-current-work.json 구조 검증 스크립트 -# -# 역할: harnish-current-work.json이 harnish가 파싱할 수 있는 올바른 구조인지 검증한다. -# 세션 시작 시, 마일스톤 도달 시 자동 실행. -# -# 사용법: -# bash validate-progress.sh [harnish-current-work.json 경로] -# bash validate-progress.sh # 현재 디렉토리의 harnish-current-work.json -# -# 종료 코드: -# 0 — 구조 정상 -# 1 — 구조 오류 발견 (상세 내용은 stderr) - -set -euo pipefail - +# validate-progress.sh — Python migration wrapper (v0.1.0+) +# 실제 구현: scripts/harnish_py/progress.py SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -PROGRESS_FILE="${1:-$(resolve_progress_file)}" - -# ═══════════════════════════════════════ -# 파일 존재 확인 -# ═══════════════════════════════════════ -if [[ ! -f "$PROGRESS_FILE" ]]; then - echo "오류: harnish-current-work.json 없음: $PROGRESS_FILE" >&2 - exit 1 -fi - -# ═══════════════════════════════════════ -# JSON 유효성 확인 -# ═══════════════════════════════════════ -if ! jq empty "$PROGRESS_FILE" 2>/dev/null; then - echo "오류: 유효한 JSON이 아닙니다: $PROGRESS_FILE" >&2 - exit 1 -fi - -ERRORS=() -WARNINGS=() - -# ═══════════════════════════════════════ -# 필수 최상위 키 검증 -# ═══════════════════════════════════════ -for key in metadata done doing todo; do - if ! jq -e ".$key" "$PROGRESS_FILE" >/dev/null 2>&1; then - ERRORS+=("필수 키 누락: '$key'") - fi -done - -# ═══════════════════════════════════════ -# 메타데이터 필드 검증 -# ═══════════════════════════════════════ -for field in prd started_at last_session status; do - if ! jq -e ".metadata.$field" "$PROGRESS_FILE" >/dev/null 2>&1; then - ERRORS+=("메타데이터 필수 필드 누락: '$field'") - fi -done - -# ═══════════════════════════════════════ -# 상태 이모지 검증 -# ═══════════════════════════════════════ -EMOJI=$(jq -r '.metadata.status.emoji // ""' "$PROGRESS_FILE") -if [[ -n "$EMOJI" ]]; then - case "$EMOJI" in - "🟢"|"🟡"|"🔴"|"✅"|"🔵") ;; - *) WARNINGS+=("현재 상태에 유효한 상태 이모지(🟢🟡🔴✅) 없음: '$EMOJI'");; - esac -fi - -# ═══════════════════════════════════════ -# Doing 태스크 필수 필드 검증 -# ═══════════════════════════════════════ -DOING_TASK=$(jq -r '.doing.task // "null"' "$PROGRESS_FILE") -if [[ "$DOING_TASK" != "null" ]]; then - for field in id title started_at current next_action; do - val=$(jq -r ".doing.task.$field // \"\"" "$PROGRESS_FILE") - if [[ -z "$val" ]]; then - WARNINGS+=("진행 중 태스크에 '$field' 필드 누락 — 세션 복원 정확도 저하") - fi - done -fi - -# ═══════════════════════════════════════ -# Done 태스크 구조 검증 -# ═══════════════════════════════════════ -DONE_TASKS=$(jq '[.done.phases[] | select(.compressed != true) | .tasks[]] | length' "$PROGRESS_FILE" 2>/dev/null || echo "0") -if [[ "$DONE_TASKS" -gt 0 ]]; then - NO_RESULT=$(jq '[.done.phases[] | select(.compressed != true) | .tasks[] | select(.result == null or .result == "")] | length' "$PROGRESS_FILE" 2>/dev/null || echo "0") - if [[ "$NO_RESULT" -gt 0 ]]; then - WARNINGS+=("완료된 태스크 ${NO_RESULT}건에 'result' 필드 없음") - fi -fi - -# ═══════════════════════════════════════ -# 선택 키 검증 -# ═══════════════════════════════════════ -for key in issues violations escalations stats; do - if ! jq -e ".$key" "$PROGRESS_FILE" >/dev/null 2>&1; then - WARNINGS+=("선택 키 누락: '$key' — 있으면 추적이 용이") - fi -done - -# ═══════════════════════════════════════ -# 결과 출력 -# ═══════════════════════════════════════ -if [[ ${#ERRORS[@]} -gt 0 ]]; then - echo "❌ harnish-current-work.json 구조 오류 발견:" >&2 - for err in "${ERRORS[@]}"; do - echo " • $err" >&2 - done -fi - -if [[ ${#WARNINGS[@]} -gt 0 ]]; then - echo "⚠️ 경고:" >&2 - for warn in "${WARNINGS[@]}"; do - echo " • $warn" >&2 - done -fi - -if [[ ${#ERRORS[@]} -eq 0 ]]; then - echo "✅ harnish-current-work.json 구조 정상 (경고 ${#WARNINGS[@]}건)" - exit 0 -else - echo "❌ 구조 오류 ${#ERRORS[@]}건, 경고 ${#WARNINGS[@]}건" >&2 - exit 1 -fi +PYTHONPATH="$SCRIPT_DIR" exec python3 -m harnish_py validate-progress "$@" diff --git a/skills/drafti-architect/SKILL.ko.md b/skills/drafti-architect/SKILL.ko.md index c73939f..b65c664 100644 --- a/skills/drafti-architect/SKILL.ko.md +++ b/skills/drafti-architect/SKILL.ko.md @@ -1,6 +1,6 @@ --- name: drafti-architect -version: 0.0.5 +version: 0.1.0 description: > 기술 설계 PRD 생성기. 기획 문서 없이 기술 문제 정의만으로 구현 가능한 PRD를 생성한다. 트리거: "drafti-architect", "drafti", "drafti 설계", "설계해", "아키텍처 PRD", diff --git a/skills/drafti-architect/SKILL.md b/skills/drafti-architect/SKILL.md index 56af232..abebd7b 100644 --- a/skills/drafti-architect/SKILL.md +++ b/skills/drafti-architect/SKILL.md @@ -1,6 +1,6 @@ --- name: drafti-architect -version: 0.0.5 +version: 0.1.0 description: > Technical design PRD generator. Creates an implementation-ready PRD from a technical problem definition alone, without a planning document. Triggers: "drafti-architect", "drafti", "drafti 설계", "설계해", "design this", "아키텍처 PRD", "architecture PRD", diff --git a/skills/drafti-feature/SKILL.ko.md b/skills/drafti-feature/SKILL.ko.md index 8c5bd6b..fbe7cc7 100644 --- a/skills/drafti-feature/SKILL.ko.md +++ b/skills/drafti-feature/SKILL.ko.md @@ -1,6 +1,6 @@ --- name: drafti-feature -version: 0.0.5 +version: 0.1.0 description: > 기획 기반 구현 명세 PRD 생성기. 기획 요구사항을 구현 가능한 명세로 변환한다. 트리거: "drafti-feature", "drafti", "drafti 피쳐", "이 기획서로 PRD 만들어", "피쳐 PRD", diff --git a/skills/drafti-feature/SKILL.md b/skills/drafti-feature/SKILL.md index 1977aa5..7bf7b29 100644 --- a/skills/drafti-feature/SKILL.md +++ b/skills/drafti-feature/SKILL.md @@ -1,6 +1,6 @@ --- name: drafti-feature -version: 0.0.5 +version: 0.1.0 description: > Planning-based implementation spec PRD generator. Converts planning requirements into an implementation-ready spec. Triggers: "drafti-feature", "drafti", "drafti 피쳐", "이 기획서로 PRD 만들어", "create PRD from this planning doc", diff --git a/skills/forki/SKILL.ko.md b/skills/forki/SKILL.ko.md index 13e8b8e..4fe181d 100644 --- a/skills/forki/SKILL.ko.md +++ b/skills/forki/SKILL.ko.md @@ -1,6 +1,6 @@ --- name: forki -version: 0.0.5 +version: 0.1.0 description: > 의사결정 강제 스킬. 문제를 역할 분해(Decision/Execution/Validation/Recovery)로 2지선택으로 환원, trade-off를 드러내고 단일 선택을 강제. diff --git a/skills/forki/SKILL.md b/skills/forki/SKILL.md index a7ad523..988609e 100644 --- a/skills/forki/SKILL.md +++ b/skills/forki/SKILL.md @@ -1,6 +1,6 @@ --- name: forki -version: 0.0.5 +version: 0.1.0 description: > Decision-forcing skill. Reduces a problem to a binary fork via role decomposition (Decision / Execution / Validation / Recovery), surfaces trade-offs, forces a single choice. diff --git a/skills/impl/SKILL.ko.md b/skills/impl/SKILL.ko.md index 3e42292..03945db 100644 --- a/skills/impl/SKILL.ko.md +++ b/skills/impl/SKILL.ko.md @@ -1,6 +1,6 @@ --- name: impl -version: 0.0.5 +version: 0.1.0 description: > 자율 구현 엔진 ("harnish" 엔진). PRD→태스크 분해, ralph 루프 자율 실행, 세션 간 맥락 유지, 경험 축적. 트리거: "impl", "harnish", "harnish 시작", "harnish 돌려", "harnish 이어서", @@ -31,7 +31,7 @@ harnish 시작 시 PRD 없으면: "PRD가 없습니다. /drafti-architect 또는 ## Bash 컨벤션 -> bash 3.2+, python3, jq. macOS/Linux. +> bash 3.2+, python3 (3.14+). macOS/Linux. v0.1.0부터 jq 의존성 없음. 각 Bash 도구 호출은 새 subshell — 변수는 호출 간 **살아남지 않는다**. 이 스킬의 모든 bash 블록은 자기 안에서 `HARNISH_ROOT="${CLAUDE_PLUGIN_ROOT}"`를 다시 선언한 후 스크립트 경로를 사용한다. 영구 alias 없음; full path (`$HARNISH_ROOT/scripts/{name}.sh`) 직접 사용. diff --git a/skills/impl/SKILL.md b/skills/impl/SKILL.md index 19ceacd..b5d6c0f 100644 --- a/skills/impl/SKILL.md +++ b/skills/impl/SKILL.md @@ -1,6 +1,6 @@ --- name: impl -version: 0.0.5 +version: 0.1.0 description: > Autonomous implementation engine (the "harnish" engine). PRD to task decomposition, ralph loop autonomous execution, cross-session context preservation, experience accumulation. Triggers: "impl", "harnish", "harnish 시작", "harnish 돌려", "harnish 이어서", @@ -31,7 +31,7 @@ When harnish starts without a PRD: "No PRD found. Please create one first with / ## Bash Convention -> bash 3.2+, python3, jq. macOS/Linux. +> bash 3.2+, python3 (3.14+). macOS/Linux. No jq required as of v0.1.0. Each Bash tool invocation is a fresh subshell — variables do **not** survive across calls. Every bash block in this skill must re-declare `HARNISH_ROOT="${CLAUDE_PLUGIN_ROOT}"` inline before using any script path. There are no persistent script-name aliases; full paths (`$HARNISH_ROOT/scripts/{name}.sh`) are used directly. diff --git a/skills/impl/references/schema.json b/skills/impl/references/schema.json index d8da6e3..42a75a3 100644 --- a/skills/impl/references/schema.json +++ b/skills/impl/references/schema.json @@ -208,7 +208,7 @@ "L1_Storage": { "files": ["scripts/common.sh", "scripts/init-assets.sh"], "rule": "디렉토리·인덱스 관리, 공용 함수. L2 이상을 호출하지 않음.", - "exports": ["require_cmd()", "resolve_base_dir()", "resolve_progress_file()", "resolve_rag_file()", "resolve_skill_dir()", "resolve_sections_file()", "slugify()"] + "exports": ["resolve_base_dir()", "resolve_progress_file()", "resolve_asset_file()", "resolve_legacy_asset_file()", "resolve_rag_file()", "resolve_skill_dir()", "resolve_sections_file()", "slugify()"] }, "L2_Operation": { "files": ["scripts/record-asset.sh", "scripts/quality-gate.sh", "scripts/localize-asset.sh", "scripts/abstract-asset.sh"], diff --git a/skills/ralphi/SKILL.ko.md b/skills/ralphi/SKILL.ko.md index 34b1408..75ad87a 100644 --- a/skills/ralphi/SKILL.ko.md +++ b/skills/ralphi/SKILL.ko.md @@ -1,6 +1,6 @@ --- name: ralphi -version: 0.0.5 +version: 0.1.0 description: > 점검 스킬. 트리거: "점검해", "확인해", "검증해", "ralphi", "셀프점검", "커버리지 확인", "테스트 갭", diff --git a/skills/ralphi/SKILL.md b/skills/ralphi/SKILL.md index 6129060..0007d7b 100644 --- a/skills/ralphi/SKILL.md +++ b/skills/ralphi/SKILL.md @@ -1,6 +1,6 @@ --- name: ralphi -version: 0.0.5 +version: 0.1.0 description: > Inspection skill. Triggers: "점검해", "확인해", "검증해", "ralphi", "셀프점검", "커버리지 확인", "테스트 갭", diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..efc856b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +"""pytest shared config — makes scripts/ importable.""" +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPTS_DIR = REPO_ROOT / "scripts" + +sys.path.insert(0, str(SCRIPTS_DIR)) diff --git a/tests/e2e_pipeline.bats b/tests/e2e_pipeline.bats index 390a80c..01632ad 100644 --- a/tests/e2e_pipeline.bats +++ b/tests/e2e_pipeline.bats @@ -117,7 +117,7 @@ teardown() { grep -q "## .*Patterns" "$SKILL_FILE" grep -q "## .*Guardrails" "$SKILL_FILE" grep -q "## .*Failures" "$SKILL_FILE" - grep -q "skillify_version: 0.0.5" "$SKILL_FILE" + grep -q "skillify_version: 0.1.0" "$SKILL_FILE" # references에 3건 자산 보존 REFS_COUNT=$(wc -l < "$REFS_FILE" | xargs) diff --git a/tests/unit_asset_test.py b/tests/unit_asset_test.py new file mode 100644 index 0000000..75aea9a --- /dev/null +++ b/tests/unit_asset_test.py @@ -0,0 +1,46 @@ +"""Unit tests for harnish_py.asset — type metadata, abstract/localize.""" +from harnish_py.asset import VALID_TYPES, TYPE_EXTRAS, _find_by_slug, _append_record +from harnish_py.io import jsonl_read, jsonl_append + + +def test_valid_types_complete(): + assert VALID_TYPES == {"failure", "pattern", "guardrail", "snippet", "decision"} + + +def test_type_extras_keys(): + for t in VALID_TYPES: + assert t in TYPE_EXTRAS + + +def test_find_by_slug(tmp_path): + p = tmp_path / "assets.jsonl" + jsonl_append(p, {"slug": "abc", "title": "found"}) + jsonl_append(p, {"slug": "xyz", "title": "other"}) + result = _find_by_slug(p, "abc") + assert result is not None + assert result["title"] == "found" + + +def test_find_by_slug_missing(tmp_path): + p = tmp_path / "assets.jsonl" + jsonl_append(p, {"slug": "abc"}) + assert _find_by_slug(p, "nope") is None + + +def test_append_record_creates_file(tmp_path): + p = tmp_path / "assets.jsonl" + p.touch() + _append_record(p, {"slug": "new", "type": "pattern"}) + records = list(jsonl_read(p)) + assert len(records) == 1 + assert records[0]["slug"] == "new" + + +def test_append_record_preserves_existing(tmp_path): + p = tmp_path / "assets.jsonl" + jsonl_append(p, {"slug": "old"}) + _append_record(p, {"slug": "new"}) + records = list(jsonl_read(p)) + assert len(records) == 2 + assert records[0]["slug"] == "old" + assert records[1]["slug"] == "new" diff --git a/tests/unit_cli_test.py b/tests/unit_cli_test.py new file mode 100644 index 0000000..f95aaab --- /dev/null +++ b/tests/unit_cli_test.py @@ -0,0 +1,27 @@ +"""Unit tests for harnish_py.cli — version guard, --version, unknown subcommand.""" +import sys +import unittest.mock +from harnish_py import __version__ +from harnish_py.cli import main + + +def test_version_guard_returns_4(): + with unittest.mock.patch.object(sys, "version_info", (3, 9)): + result = main([]) + assert result == 4 + + +def test_version_output(capsys): + try: + main(["--version"]) + except SystemExit as e: + assert e.code == 0 + captured = capsys.readouterr() + assert __version__ in captured.out + + +def test_unknown_subcommand_exits_1(): + try: + result = main(["nonexistent-cmd"]) + except SystemExit as e: + assert e.code == 1 diff --git a/tests/unit_compress_test.py b/tests/unit_compress_test.py new file mode 100644 index 0000000..d015dcc --- /dev/null +++ b/tests/unit_compress_test.py @@ -0,0 +1,68 @@ +"""Unit tests for harnish_py.compress — threshold, dry-run, grouping.""" +import json +import types +import io +import contextlib +from harnish_py.io import jsonl_append, jsonl_read +from harnish_py.compress import _cmd_compress + + +def _setup(tmp_path, n=6): + base = tmp_path / ".harnish" + base.mkdir() + af = base / "harnish-assets.jsonl" + for i in range(n): + jsonl_append(af, { + "slug": f"p{i}", "type": "pattern", "title": f"t{i}", + "tags": ["test-tag"], "date": "2026-01-01", + "scope": "generic", "body": "b", "context": "c", "session": "s", + }) + return str(base) + + +def test_compress_marks_records(tmp_path): + base = _setup(tmp_path, 6) + args = types.SimpleNamespace( + tag="test-tag", all_tags=False, threshold=5, + dry_run=False, base_dir=base, + ) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + _cmd_compress(args) + out = json.loads(buf.getvalue()) + assert out["status"] == "compressed" + assert out["compressed"] == 1 + + records = list(jsonl_read(f"{base}/harnish-assets.jsonl")) + compressed = [r for r in records if r.get("compressed") is True] + assert len(compressed) >= 6 # 6 originals marked + maybe summary + + +def test_dry_run_no_change(tmp_path): + base = _setup(tmp_path, 6) + af = f"{base}/harnish-assets.jsonl" + before = open(af).read() + + args = types.SimpleNamespace( + tag="test-tag", all_tags=False, threshold=5, + dry_run=True, base_dir=base, + ) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + _cmd_compress(args) + out = json.loads(buf.getvalue()) + assert out["status"] == "dry_run" + assert open(af).read() == before + + +def test_below_threshold_noop(tmp_path): + base = _setup(tmp_path, 3) + args = types.SimpleNamespace( + tag="", all_tags=True, threshold=5, + dry_run=False, base_dir=base, + ) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + _cmd_compress(args) + out = json.loads(buf.getvalue()) + assert out["compressed"] == 0 diff --git a/tests/unit_detect_test.py b/tests/unit_detect_test.py new file mode 100644 index 0000000..a3b4524 --- /dev/null +++ b/tests/unit_detect_test.py @@ -0,0 +1,60 @@ +"""Unit tests for harnish_py.detect — hook event routing.""" +import io +import json +import os +import sys +import types +import unittest.mock +from pathlib import Path + +import pytest + + +def _run_detect(stdin_data: str, base_dir: str, env: dict | None = None): + """Helper: run _cmd_detect with given stdin and base_dir.""" + from harnish_py.detect import _cmd_detect + + env = env or {} + fake_args = types.SimpleNamespace(base_dir=base_dir) + with unittest.mock.patch.dict(os.environ, env): + with unittest.mock.patch("sys.stdin", io.StringIO(stdin_data)): + with unittest.mock.patch("sys.stdin.isatty", return_value=False): + return _cmd_detect(fake_args) + + +def test_post_tool_use_reports_pending(tmp_path): + """PostToolUse event triggers _report_pending (pending count visible).""" + harnish_dir = tmp_path / ".harnish" + harnish_dir.mkdir() + + session = "unit-detect-postuse" + pending_file = Path(f"/tmp/harnish-pending-{session}.jsonl") + pending_file.write_text('{"event":"x"}\n{"event":"y"}\n', encoding="utf-8") + + try: + event = json.dumps({"hook_event_name": "PostToolUse", "session_id": session}) + captured = io.StringIO() + with unittest.mock.patch("sys.stdout", captured): + code = _run_detect(event, str(harnish_dir), {"CLAUDE_SESSION_ID": session}) + assert code == 0 + output = captured.getvalue() + assert "2건" in output or "pending" in output + finally: + pending_file.unlink(missing_ok=True) + + +def test_post_tool_use_no_pending_silent(tmp_path): + """PostToolUse with no pending file → exits 0 and prints nothing.""" + harnish_dir = tmp_path / ".harnish" + harnish_dir.mkdir() + + session = "unit-detect-no-pending" + pending_file = Path(f"/tmp/harnish-pending-{session}.jsonl") + pending_file.unlink(missing_ok=True) + + event = json.dumps({"hook_event_name": "PostToolUse", "session_id": session}) + captured = io.StringIO() + with unittest.mock.patch("sys.stdout", captured): + code = _run_detect(event, str(harnish_dir), {"CLAUDE_SESSION_ID": session}) + assert code == 0 + assert captured.getvalue().strip() == "" diff --git a/tests/unit_init_test.py b/tests/unit_init_test.py new file mode 100644 index 0000000..27e49b2 --- /dev/null +++ b/tests/unit_init_test.py @@ -0,0 +1,28 @@ +"""Unit tests for harnish_py.init — legacy migration, idempotency.""" +from pathlib import Path +from harnish_py.init import init_assets + + +def test_init_creates_files(tmp_path): + base = tmp_path / ".harnish" + init_assets(base_dir=str(base), quiet=True) + assert (base / "harnish-assets.jsonl").exists() + assert (base / "harnish-current-work.json").exists() + + +def test_init_idempotent(tmp_path): + base = tmp_path / ".harnish" + init_assets(base_dir=str(base), quiet=True) + (base / "harnish-assets.jsonl").write_text('{"existing":true}\n') + init_assets(base_dir=str(base), quiet=True) + assert '{"existing":true}' in (base / "harnish-assets.jsonl").read_text() + + +def test_legacy_migration(tmp_path): + base = tmp_path / ".harnish" + base.mkdir() + legacy = base / "harnish-rag.jsonl" + legacy.write_text('{"old":"data"}\n') + init_assets(base_dir=str(base), quiet=True) + assert not legacy.exists() + assert (base / "harnish-assets.jsonl").read_text() == '{"old":"data"}\n' diff --git a/tests/unit_io_test.py b/tests/unit_io_test.py new file mode 100644 index 0000000..082030f --- /dev/null +++ b/tests/unit_io_test.py @@ -0,0 +1,66 @@ +"""Unit tests for harnish_py.io — atomic_write, jsonl_read, jsonl_append.""" +import json +import pytest +from pathlib import Path +from harnish_py.io import atomic_write, jsonl_read, jsonl_append, jsonl_rewrite + + +def test_atomic_write_creates_file(tmp_path): + p = tmp_path / "sub" / "test.txt" + atomic_write(p, "hello") + assert p.read_text() == "hello" + + +def test_atomic_write_bytes(tmp_path): + p = tmp_path / "bin.dat" + atomic_write(p, b"\x00\x01\x02") + assert p.read_bytes() == b"\x00\x01\x02" + + +def test_jsonl_read_missing_file(tmp_path): + p = tmp_path / "no.jsonl" + assert list(jsonl_read(p)) == [] + + +def test_jsonl_read_empty_file(tmp_path): + p = tmp_path / "empty.jsonl" + p.write_text("") + assert list(jsonl_read(p)) == [] + + +def test_jsonl_append_and_read(tmp_path): + p = tmp_path / "data.jsonl" + jsonl_append(p, {"a": 1}) + jsonl_append(p, {"b": 2}) + records = list(jsonl_read(p)) + assert len(records) == 2 + assert records[0] == {"a": 1} + assert records[1] == {"b": 2} + + +def test_jsonl_rewrite(tmp_path): + p = tmp_path / "data.jsonl" + jsonl_append(p, {"old": True}) + jsonl_rewrite(p, [{"new": True}]) + records = list(jsonl_read(p)) + assert len(records) == 1 + assert records[0] == {"new": True} + + +def test_jsonl_korean_roundtrip(tmp_path): + p = tmp_path / "ko.jsonl" + jsonl_append(p, {"title": "한글 테스트", "body": "내용"}) + records = list(jsonl_read(p)) + assert records[0]["title"] == "한글 테스트" + + +def test_jsonl_read_truncated_warns(tmp_path, capsys): + p = tmp_path / "truncated.jsonl" + jsonl_append(p, {"ok": 1}) + # Append partial JSON simulating a truncated write + with open(p, "a", encoding="utf-8") as f: + f.write('{"incomplete": true') + records = list(jsonl_read(p)) + assert len(records) == 1 # only the complete record + captured = capsys.readouterr() + assert "incomplete JSON" in captured.err diff --git a/tests/unit_migrate_test.py b/tests/unit_migrate_test.py new file mode 100644 index 0000000..5e491de --- /dev/null +++ b/tests/unit_migrate_test.py @@ -0,0 +1,63 @@ +"""Unit tests for harnish_py.migrate — backfill, no-op, backup gating.""" +import json +from pathlib import Path +from harnish_py.io import jsonl_append, jsonl_read +from harnish_py.migrate import _cmd_migrate +import types + + +def _args(base_dir, target="0.0.2"): + return types.SimpleNamespace(base_dir=str(base_dir), target=target) + + +def test_migrate_backfills_v001(tmp_path, capsys): + base = tmp_path / ".harnish" + base.mkdir() + f = base / "harnish-assets.jsonl" + jsonl_append(f, {"slug": "a", "schema_version": "0.0.1", "date": "2025-01-01"}) + rc = _cmd_migrate(_args(base)) + assert rc == 0 + captured = capsys.readouterr() + out = json.loads(captured.out) + assert out["status"] == "migrated" + assert out["migrated"] == 1 + records = list(jsonl_read(f)) + assert records[0]["schema_version"] == "0.0.2" + assert records[0]["access_count"] == 0 + + +def test_migrate_noop_when_all_current(tmp_path, capsys): + base = tmp_path / ".harnish" + base.mkdir() + f = base / "harnish-assets.jsonl" + jsonl_append(f, {"slug": "b", "schema_version": "0.0.2"}) + rc = _cmd_migrate(_args(base)) + assert rc == 0 + captured = capsys.readouterr() + out = json.loads(captured.out) + assert out["status"] == "no-op" + # No backup created + bak_files = list(base.glob("harnish-assets.jsonl.bak.*")) + assert len(bak_files) == 0 + + +def test_migrate_creates_backup_only_when_migrating(tmp_path): + base = tmp_path / ".harnish" + base.mkdir() + f = base / "harnish-assets.jsonl" + jsonl_append(f, {"slug": "c", "schema_version": "0.0.1", "date": "2025-01-01"}) + _cmd_migrate(_args(base)) + bak_files = list(base.glob("harnish-assets.jsonl.bak.*")) + assert len(bak_files) == 1 + + +def test_migrate_noop_empty_file(tmp_path, capsys): + base = tmp_path / ".harnish" + base.mkdir() + f = base / "harnish-assets.jsonl" + f.write_text("") + rc = _cmd_migrate(_args(base)) + assert rc == 0 + captured = capsys.readouterr() + out = json.loads(captured.out) + assert out["status"] == "no-op" diff --git a/tests/unit_progress_test.py b/tests/unit_progress_test.py new file mode 100644 index 0000000..fa99a49 --- /dev/null +++ b/tests/unit_progress_test.py @@ -0,0 +1,86 @@ +"""Unit tests for harnish_py.progress — validate schema, loop-step, compress.""" +import json +import types +import io +import contextlib +from pathlib import Path +from harnish_py.progress import _cmd_validate, _cmd_loop_step, _cmd_compress_progress + + +VALID_PROGRESS = { + "metadata": { + "prd": "docs/prd.md", + "started_at": "2026-01-01T00:00:00", + "last_session": "2026-01-01T01:00:00", + "status": {"emoji": "🟢", "phase": 1, "task": "1-1", "label": "ok"}, + }, + "done": {"phases": []}, + "doing": { + "task": { + "id": "1-1", "title": "test", "started_at": "x", + "current": "x", "next_action": "do something", + } + }, + "todo": {"phases": [{"phase": 1, "title": "P1", + "tasks": [{"id": "1-2", "title": "t2", "depends_on": []}]}]}, + "issues": [], "violations": [], "escalations": [], + "stats": {"total_phases": 1, "completed_phases": 0, + "total_tasks": 2, "completed_tasks": 0, + "issues_count": 0, "violations_count": 0}, +} + + +def _write_progress(tmp_path, data=None): + p = tmp_path / "work.json" + p.write_text(json.dumps(data or VALID_PROGRESS, ensure_ascii=False)) + return str(p) + + +def test_validate_pass(tmp_path): + path = _write_progress(tmp_path) + args = types.SimpleNamespace(progress_file=path) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + result = _cmd_validate(args) + assert result == 0 + + +def test_validate_broken_json(tmp_path): + p = tmp_path / "bad.json" + p.write_text("{bad") + args = types.SimpleNamespace(progress_file=str(p)) + result = _cmd_validate(args) + assert result == 1 + + +def test_validate_missing_keys(tmp_path): + p = tmp_path / "empty.json" + p.write_text("{}") + args = types.SimpleNamespace(progress_file=str(p)) + result = _cmd_validate(args) + assert result == 1 + + +def test_loop_step_active(tmp_path, capsys): + path = _write_progress(tmp_path) + args = types.SimpleNamespace(progress_file=path, fmt="json") + result = _cmd_loop_step(args) + assert result == 0 + out = json.loads(capsys.readouterr().out) + assert out["status"] == "ACTIVE" + assert out["current_task"] == "1-1" + + +def test_loop_step_all_done(tmp_path, capsys): + data = dict(VALID_PROGRESS) + data = json.loads(json.dumps(data)) # deep copy + data["doing"] = {"task": None} + data["todo"] = {"phases": []} + data["done"] = {"phases": [{"phase": 1, "title": "done", "compressed": False, + "tasks": [{"id": "1-1", "title": "t", "result": "ok", + "files_changed": []}]}]} + path = _write_progress(tmp_path, data) + args = types.SimpleNamespace(progress_file=path, fmt="json") + _cmd_loop_step(args) + out = json.loads(capsys.readouterr().out) + assert out["status"] == "ALL_DONE" diff --git a/tests/unit_promote_dedup_test.py b/tests/unit_promote_dedup_test.py new file mode 100644 index 0000000..d15a066 --- /dev/null +++ b/tests/unit_promote_dedup_test.py @@ -0,0 +1,65 @@ +"""Unit tests for harnish_py.promote — dedup key, occurrences, empty file.""" +import json +import os +import types +import tempfile +from pathlib import Path +from harnish_py.io import jsonl_read + + +def _make_pending(path, entries): + with open(path, "w", encoding="utf-8") as f: + for e in entries: + f.write(json.dumps(e, ensure_ascii=False) + "\n") + + +def test_dedup_by_tool_and_first_line(tmp_path): + base = tmp_path / ".harnish" + base.mkdir() + (base / "harnish-assets.jsonl").touch() + (base / "harnish-current-work.json").write_text("{}") + + pending = tmp_path / "pending.jsonl" + _make_pending(pending, [ + {"tool": "Bash", "output": "Error: foo\ndetail", "session": "s", "date": "2026-01-01T00:00:00"}, + {"tool": "Bash", "output": "Error: foo\nother", "session": "s", "date": "2026-01-01T00:00:01"}, + {"tool": "Bash", "output": "Error: bar\ndetail", "session": "s", "date": "2026-01-01T00:00:02"}, + ]) + + from harnish_py.promote import _cmd_promote + args = types.SimpleNamespace( + session="test-dedup", base_dir=str(base), dry_run=True, + ) + + # Monkey-patch the pending file path + import harnish_py.promote as pm + orig_path = f"/tmp/harnish-pending-test-dedup.jsonl" + import shutil + shutil.copy2(pending, orig_path) + + import io, contextlib + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + _cmd_promote(args) + out = json.loads(buf.getvalue()) + + assert out["status"] == "dry_run" + assert out["promoted"] == 2 # foo and bar + assert out["deduplicated"] == 1 # one foo duplicate + + # cleanup + Path(orig_path).unlink(missing_ok=True) + + +def test_empty_pending_returns_empty(tmp_path): + from harnish_py.promote import _cmd_promote + import io, contextlib + + args = types.SimpleNamespace( + session="nonexistent-session-xyz", base_dir=str(tmp_path), dry_run=False, + ) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + _cmd_promote(args) + out = json.loads(buf.getvalue()) + assert out["status"] == "no_pending" diff --git a/tests/unit_purge_test.py b/tests/unit_purge_test.py new file mode 100644 index 0000000..129546d --- /dev/null +++ b/tests/unit_purge_test.py @@ -0,0 +1,69 @@ +"""Unit tests for harnish_py.purge — TTL boundary, dry-run, pattern immunity.""" +import json +import types +import io +import contextlib +from harnish_py.io import jsonl_append, jsonl_read +from harnish_py.purge import _cmd_purge + + +def test_dry_run_no_change(tmp_path): + base = tmp_path / ".harnish" + base.mkdir() + af = base / "harnish-assets.jsonl" + jsonl_append(af, { + "slug": "old", "type": "decision", "title": "old", + "tags": ["x"], "date": "2020-01-01", "scope": "generic", + "body": "b", "context": "c", "session": "s", + "last_accessed_at": "2020-01-01", "access_count": 0, + }) + before = af.read_text() + + args = types.SimpleNamespace(execute=False, base_dir=str(base)) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + _cmd_purge(args) + out = json.loads(buf.getvalue()) + assert out["status"] == "dry_run" + assert af.read_text() == before + + +def test_pattern_never_purged(tmp_path): + base = tmp_path / ".harnish" + base.mkdir() + af = base / "harnish-assets.jsonl" + jsonl_append(af, { + "slug": "old-pattern", "type": "pattern", "title": "p", + "tags": ["x"], "date": "2020-01-01", "scope": "generic", + "body": "b", "context": "c", "session": "s", + }) + + args = types.SimpleNamespace(execute=True, base_dir=str(base)) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + _cmd_purge(args) + out = json.loads(buf.getvalue()) + assert out.get("purged", 0) == 0 or out["status"] == "no_candidates" + + +def test_execute_archives(tmp_path): + base = tmp_path / ".harnish" + base.mkdir() + af = base / "harnish-assets.jsonl" + jsonl_append(af, { + "slug": "expired", "type": "failure", "title": "old fail", + "tags": ["x"], "date": "2020-01-01", "scope": "generic", + "body": "b", "context": "c", "session": "s", + "access_count": 0, + }) + + args = types.SimpleNamespace(execute=True, base_dir=str(base)) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + _cmd_purge(args) + out = json.loads(buf.getvalue()) + assert out["status"] == "purged" + assert out["purged"] == 1 + + archive = base / "harnish-assets-archive.jsonl" + assert archive.exists() diff --git a/tests/unit_query_filter_test.py b/tests/unit_query_filter_test.py new file mode 100644 index 0000000..d995860 --- /dev/null +++ b/tests/unit_query_filter_test.py @@ -0,0 +1,81 @@ +"""Unit tests for harnish_py.query — tag filtering, type filtering, formats.""" +import json +import os +import tempfile +from pathlib import Path +from harnish_py.io import jsonl_append +from harnish_py.query import query_assets + + +def _setup(tmp_path): + base = tmp_path / ".harnish" + base.mkdir() + af = base / "harnish-assets.jsonl" + jsonl_append(af, {"slug": "a", "type": "failure", "title": "err", + "tags": ["api", "retry"], "date": "2026-01-01", + "scope": "generic", "body": "body-a", "context": "ctx"}) + jsonl_append(af, {"slug": "b", "type": "pattern", "title": "pat", + "tags": ["db"], "date": "2026-01-02", + "scope": "generic", "body": "body-b", "context": "ctx"}) + jsonl_append(af, {"slug": "c", "type": "failure", "title": "err2", + "tags": ["api"], "date": "2026-01-03", + "scope": "generic", "body": "body-c", "context": "ctx", + "compressed": True}) + return str(base) + + +def test_tag_filter_or(tmp_path, capsys): + base = _setup(tmp_path) + query_assets(tags="api", fmt="json", base_dir=base) + out = json.loads(capsys.readouterr().out) + assert out["count"] == 1 + assert out["results"][0]["slug"] == "a" + + +def test_type_filter(tmp_path, capsys): + base = _setup(tmp_path) + query_assets(tags="api,db", types="pattern", fmt="json", base_dir=base) + out = json.loads(capsys.readouterr().out) + assert out["count"] == 1 + assert out["results"][0]["type"] == "pattern" + + +def test_compressed_excluded(tmp_path, capsys): + base = _setup(tmp_path) + query_assets(tags="api", fmt="json", base_dir=base) + out = json.loads(capsys.readouterr().out) + slugs = [r["slug"] for r in out["results"]] + assert "c" not in slugs + + +def test_empty_store(tmp_path, capsys): + base = tmp_path / ".harnish" + base.mkdir() + (base / "harnish-assets.jsonl").touch() + query_assets(tags="any", fmt="text", base_dir=str(base)) + assert "검색 결과 없음" in capsys.readouterr().out + + +def test_inject_format(tmp_path, capsys): + base = _setup(tmp_path) + query_assets(tags="api", fmt="inject", base_dir=base) + out = capsys.readouterr().out + assert "관련 자산 (asset-recorder)" in out + assert "err" in out + + +def test_access_count_incremented(tmp_path, capsys): + base = _setup(tmp_path) + af = tmp_path / ".harnish" / "harnish-assets.jsonl" + # First query — access_count should go from 0 to 1 + query_assets(tags="api", fmt="json", base_dir=base) + capsys.readouterr() + from harnish_py.io import jsonl_read + records = {r["slug"]: r for r in jsonl_read(af)} + assert records["a"].get("access_count", 0) == 1 + assert records["a"]["last_accessed_at"] != "" + # Second query — access_count should be 2 + query_assets(tags="api", fmt="json", base_dir=base) + capsys.readouterr() + records2 = {r["slug"]: r for r in jsonl_read(af)} + assert records2["a"].get("access_count") == 2 diff --git a/tests/unit_record_test.py b/tests/unit_record_test.py new file mode 100644 index 0000000..1cf0980 --- /dev/null +++ b/tests/unit_record_test.py @@ -0,0 +1,41 @@ +"""Unit tests for harnish_py.record — slug dedup, required fields.""" +import json +from harnish_py.io import jsonl_read, jsonl_append +from harnish_py.common import slugify + + +def test_slugify_ascii(): + assert slugify("Hello World") == "hello-world" + + +def test_slugify_korean(): + slug = slugify("한글 제목") + assert len(slug) == 12 # md5 hex prefix + + +def test_slugify_dedup_counter(): + """Verify slug collision avoidance logic.""" + from harnish_py.record import _cmd_record + import types + import tempfile, os + from pathlib import Path + + tmp = tempfile.mkdtemp() + base = os.path.join(tmp, ".harnish") + os.makedirs(base, exist_ok=True) + Path(os.path.join(base, "harnish-assets.jsonl")).touch() + Path(os.path.join(base, "harnish-current-work.json")).write_text("{}") + + # Record same title twice + for _ in range(2): + args = types.SimpleNamespace( + type_="failure", tags="test", context="c", title="same-title", + body="b", content="", body_file="", session_id="manual", + scope="generic", base_dir=base, stdin=False, + ) + _cmd_record(args) + + records = list(jsonl_read(os.path.join(base, "harnish-assets.jsonl"))) + slugs = [r["slug"] for r in records] + assert len(set(slugs)) == 2 # no duplicates + assert slugs[1].endswith("-2") diff --git a/tests/unit_skillify_test.py b/tests/unit_skillify_test.py new file mode 100644 index 0000000..6fcfe47 --- /dev/null +++ b/tests/unit_skillify_test.py @@ -0,0 +1,77 @@ +"""Unit tests for harnish_py.skillify — scaffold generation, triggers, references.""" +import json +import types +import io +import contextlib +from pathlib import Path +from harnish_py.io import jsonl_append +from harnish_py.skillify import _cmd_skillify + + +def _setup(tmp_path, n=3): + base = tmp_path / ".harnish" + base.mkdir() + af = base / "harnish-assets.jsonl" + for i in range(n): + jsonl_append(af, { + "slug": f"f{i}", "type": "failure", "title": f"docker cache miss {i}", + "tags": ["docker"], "date": "2026-01-01", + "scope": "generic", "body": f"body content {i}", + "context": "test", "session": "s", + }) + return str(base) + + +def test_skill_md_created(tmp_path): + base = _setup(tmp_path) + out_dir = str(tmp_path / "skills-out") + + args = types.SimpleNamespace( + tag="docker", skill_name="docker-patterns", + output_dir=out_dir, base_dir=base, + ) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + _cmd_skillify(args) + + skill_md = Path(out_dir) / "docker-patterns" / "SKILL.md" + assert skill_md.exists() + content = skill_md.read_text() + assert "name: docker-patterns" in content + assert "version:" in content + assert "description:" in content + + +def test_references_preserved(tmp_path): + base = _setup(tmp_path) + out_dir = str(tmp_path / "skills-out") + + args = types.SimpleNamespace( + tag="docker", skill_name="docker-patterns", + output_dir=out_dir, base_dir=base, + ) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + _cmd_skillify(args) + + refs = Path(out_dir) / "docker-patterns" / "references" / "source-assets.jsonl" + assert refs.exists() + lines = refs.read_text().strip().splitlines() + assert len(lines) == 3 + + +def test_trigger_extraction(tmp_path): + base = _setup(tmp_path) + out_dir = str(tmp_path / "skills-out") + + args = types.SimpleNamespace( + tag="docker", skill_name="docker-patterns", + output_dir=out_dir, base_dir=base, + ) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + _cmd_skillify(args) + + content = (Path(out_dir) / "docker-patterns" / "SKILL.md").read_text() + # "docker" should appear in triggers (base trigger) + assert '"docker"' in content