diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 12a84551..3cbcf74b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -14,18 +14,12 @@ "./commands/harness-task.md", "./commands/harness-ship.md", "./commands/harness-diagram.md", - "./commands/harness-clone.md", - "./commands/harness-symlink.md", - "./commands/harness-delete.md", "./commands/harness-migrate.md", - "./commands/harness-upgrade.md", "./commands/harness-spec.md", "./commands/harness-interview.md", "./commands/harness-contrarian.md", "./commands/harness-simplifier.md", "./commands/harness-retro.md", - "./commands/harness-release.md", - "./commands/harness-sim.md", "./commands/harness-unittest.md", "./commands/harness-comptest.md", "./commands/harness-inttest.md", diff --git a/commands/harness-release.md b/.claude/commands/harness-release.md similarity index 100% rename from commands/harness-release.md rename to .claude/commands/harness-release.md diff --git a/commands/harness-sim.md b/.claude/commands/harness-sim.md similarity index 87% rename from commands/harness-sim.md rename to .claude/commands/harness-sim.md index 6afcbd58..badbf954 100644 --- a/commands/harness-sim.md +++ b/.claude/commands/harness-sim.md @@ -7,6 +7,6 @@ argument-hint: "[run|probe]" 이 커맨드는 **`harness-sim` 스킬**의 얇은 래퍼입니다 (프리픽스 슬래시 형태 제공). 절차·판정 도구·리포트 경로의 단일 소스(SSOT)는 스킬 본문입니다 — 여기서 복제하지 않습니다. -`${CLAUDE_PLUGIN_ROOT}/skills/harness-sim/SKILL.md` 를 읽고 그 절차를 그대로 실행하세요. +`scripts/maintainer-skills/harness-sim/SKILL.md` 를 읽고 그 절차를 그대로 실행하세요. 프리플라이트에서 **어느 하네스를 돌릴지** 먼저 정하고, 스킬의 **정직성 규칙**(산문은 신호가 아님, 증거 없는 PASS 금지)을 준수한 뒤, 스킬이 지정한 리포트 경로를 사용자에게 보고하세요. diff --git a/CHANGELOG.md b/CHANGELOG.md index 69086754..9494b536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ 이 저장소의 `docs/decisions.md`에만 둔다. `doctor`의 결정 로그 확인 헤딩도 D2·D4·D5·D6으로 줄였다 — 확인 대상이 줄어드는 방향이라 소비자에게 새 경고는 없다. 소비자 `AGENTS.md` 결정 규범에서 D7 줄을 뺐다(관리 절 변경이라 원할 때 `init --yes`로 받는다). 사이클 정의·결정 전문은 `docs/harness-cycle.md`. +- `/harness-sim`·`/harness-release`와 Codex 스킬 `harness-sim`·`harness-codex-sim`·`harness-release`는 이 저장소에서만 동작하므로 + 소비자 배포에서 뺐다 — 메인테이너는 `.claude/commands/`와 `scripts/maintainer-skills/`에서 쓴다. ### Removed - **개인 도구 흔적 정리** (task `remove-personal-tool-refs`). 메인테이너 개인 도구의 문서를 팀 하네스에서 뺐다: @@ -22,6 +24,11 @@ README·`docs/index.html`·`docs/harness-task-guide.html`·`docs/prerequisites.md`의 링크. 문서 33개의 Obsidian frontmatter (`tags`·`created`·`modified`, 소비자에게 가는 `templates/docs/README.md` 포함), `init`이 쓰는 gitignore의 `oh-my-openagent.json` (D7 잔재), `harness-ship` 보고 예시의 개인 스킬 줄, `docs/followups.md`의 개인 스킬 경로 문단도 지웠다. +- **백업 클론과 AI 파일 gitignore 옵션** (task `remove-backup-cluster`, D11). 저장소 밖 `../harness-backup//` 백업 클론 + (`clone.sh`·`symlink.sh`·`delete.sh`, `harness-team backup|clone|symlink|delete|upgrade`와 같은 이름의 슬래시 명령·Codex 스킬, + `.harness/backup.json`)과 init의 백업 위치·"AI 설정 파일 gitignore" 질문(`--no-backup`·`--backup-dir`·`--backup-parent`·`--gitignore-ai`)을 뺐다. + doctor는 더 이상 `backup.json`을 요구하지 않는다. 기존 백업 폴더와 `backup.json`은 건드리지 않는다. AI 설정을 gitignore한 프로젝트는 + 백업이 저장소 밖 유일 사본일 수 있으니, 원할 때 gitignore를 풀고 커밋하라. migrate의 관리 절 백업(`.harness/backup/`)은 그대로다. ## [0.45.0] - 2026-10-03 diff --git a/MAINTAINING.md b/MAINTAINING.md index 35c4dc29..ce71b268 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -40,6 +40,8 @@ | `README.md` | 명령어 레퍼런스 섹션 (전체 목록이 아니라 부분 안내) | — | | `bin/harness-team.mjs` | **CLI를 감싸는 커맨드만.** 에이전트 판단만 하는 커맨드는 CLI 서브커맨드가 없다 | `manifest-sync` | +**메인테이너 전용 표면은 배포하지 않습니다.** `/harness-sim`·`/harness-release`는 `.claude/commands/`(이 레포 전용 Claude 커맨드), 대응 스킬(`harness-sim`·`harness-codex-sim`·`harness-release`)은 `scripts/maintainer-skills/`에 둡니다 — Codex 플러그인은 `./skills/` 전체를 싣고 `scripts/`는 싣지 않기 때문입니다. `release` CLI 서브커맨드(`src/commands/release.mjs`)는 그대로입니다. + `bin` 등록은 선택이 아니라 **조건부**입니다: `commands/*.md`가 언급하는 모든 `harness-team `는 router에 case가 있어야 하고(`manifest-sync`), 반대로 CLI 없는 커맨드는 그런 표기를 문서에 쓰지 않으면 됩니다. `harness-interview`·`harness-ship`처럼 절차가 전부 diff --git a/README.md b/README.md index ae5e802a..9b18d906 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,6 @@ - [동반 플러그인 (선택)](#동반-플러그인-선택) - [명령어 레퍼런스](#명령어-레퍼런스) - [task 관리 (팀원·기능별)](#task-관리-팀원기능별) -- [스크립트 3종 사용법](#스크립트-3종-사용법) - [설치 결과물](#설치-결과물) - [CLAUDE.md 섹션 마커](#claudemd-섹션-마커) - [문서 (HTML)](#문서-html) @@ -103,7 +102,7 @@ harness-team doctor | 에이전트 | hooks | 커맨드/적용 표면 | 경로 스코프 규칙 | |---|---|---|---| -| Claude Code | 5개 이벤트 / 스크립트 6종 | 플러그인 설치 시 25개 슬래시 커맨드 | `.claude/rules` `paths:` — 매칭 파일 **Read 시** 로드 | +| Claude Code | 5개 이벤트 / 스크립트 6종 | 플러그인 설치 시 19개 슬래시 커맨드 | `.claude/rules` `paths:` — 매칭 파일 **Read 시** 로드 | | Codex | SessionStart 1종 — **신뢰 2겹이 모두 있어야 실행**(아래) | 슬래시 커맨드는 없고 별도 `.codex-plugin` 설치 시 동명의 스킬 | 없음 — 하위 디렉터리 `AGENTS.md`로 대체 | | Cursor | 0 | `.cursor/rules/*.mdc` 규칙만 (RN 계열 stack) | `.mdc` `globs:` — `.claude/rules`에서 미러 | @@ -201,11 +200,10 @@ cd my-project - `.codex-plugin/plugin.json` — Codex 플러그인 메타데이터 - `skills/harness-team/SKILL.md` — Codex에서 `harness-team` CLI와 task workflow를 사용하는 진입점 -- `skills/harness-codex-sim/SKILL.md` — `codex exec --json` 기반 Codex L5 시뮬레이션 운용 가이드 Codex 쪽 marketplace/설치 위치는 개인·팀 환경에 따라 다릅니다. 로컬 개발 중에는 이 레포를 Codex local plugin source로 등록한 뒤, 새 Codex thread에서 `harness-team` 및 `harness-*` command-equivalent skills가 노출되는지 확인하세요. -주의: Codex 플러그인은 Claude Code의 `.claude-plugin/plugin.json` `commands[]`를 같은 slash command 목록으로 가져오지 않습니다. Codex의 플러그인 표면은 `skills`, apps, MCP 서버이며, 명시 호출은 `/harness-*`가 아니라 `$harness-aijient-team:harness-init`처럼 `$` skill invocation을 사용합니다. 이 레포는 Claude의 `/harness-*` 명령에 대응하는 `skills/harness-*` 래퍼를 제공하고, 각 wrapper는 `commands/harness-*.md`를 SSOT로 읽습니다. 단, `harness-sim`은 방향이 반대로 — 커맨드가 얇은 래퍼이고 절차 SSOT는 스킬 본문입니다. +주의: Codex 플러그인은 Claude Code의 `.claude-plugin/plugin.json` `commands[]`를 같은 slash command 목록으로 가져오지 않습니다. Codex의 플러그인 표면은 `skills`, apps, MCP 서버이며, 명시 호출은 `/harness-*`가 아니라 `$harness-aijient-team:harness-init`처럼 `$` skill invocation을 사용합니다. 이 레포는 Claude의 `/harness-*` 명령에 대응하는 `skills/harness-*` 래퍼를 제공하고, 각 wrapper는 `commands/harness-*.md`를 SSOT로 읽습니다. Codex headless L5 검증은 먼저 probe로 auth/JSONL 계약을 확인한 뒤 full run을 실행합니다: @@ -216,6 +214,8 @@ node tests/sim/codex-agentloop.mjs run full run은 throwaway `../harness-playground/.sim-tmp//` 안에서 `.git/hooks/post-commit` 설치까지 검증하므로 Codex sandbox를 `danger-full-access`로 올립니다. 실제 프로젝트 디렉토리에는 실행하지 말고, 결과는 `../harness-playground/sim-reports/codex-agentloop-.md`에서 확인하세요. +메인테이너 전용 `/harness-sim`·`/harness-release`는 배포 표면이 아니라 이 레포의 `.claude/commands/`에 있고, 대응 스킬(`harness-sim`·`harness-codex-sim`·`harness-release`)은 `scripts/maintainer-skills/`에 있습니다 — 플러그인에 번들되지 않습니다. + ### 방법 C: 독립 CLI 이 패키지는 npm 공개 저장소에 배포되지 않습니다. 전역 `harness-team` CLI는 Claude Code @@ -306,8 +306,6 @@ React Native/Expo 전용 rules 4종은 유효 stack이 RN 계열일 때만 설 - `.claude/rules/*.md` → `.cursor/rules/*.mdc` 미러링 갱신 (단방향; 원본이 사라진 미러는 정리) - git post-commit 훅 재설치 (`core.hooksPath`·worktree는 `git rev-parse --git-path hooks`로 따라감) -> ⚠️ `symlink.sh` 와는 **다른 기능**입니다. 아래 [스크립트 3종](#스크립트-3종-사용법) 참조. - ### `/harness-doctor` — 무결성 점검 symlink · JSON 유효성 · 실행 권한 · 외부 도구 존재 여부 체크. @@ -413,40 +411,9 @@ contrarian·simplifier)은 이 명령의 절차·엔진 표를 재사용하며 ` 구분됩니다 — 루브릭·마커 계약·증거 게이트의 전체 그림은 [docs/harness-rubric-guide.html](docs/harness-rubric-guide.html)에 있습니다. -### `/harness-clone` — project → backup dir 동기화 - -프로젝트 파일을 백업 디렉토리로 복사(merge, newer-wins). 이미 harness symlink인 항목은 건너뜁니다. - -```bash -/harness-clone -/harness-clone --backup-dir ~/my-backups/project-a -``` - -### `/harness-symlink` — backup dir → project symlink 생성 - -백업 디렉토리의 harness 아티팩트를 프로젝트 루트로 symlink합니다. - -```bash -/harness-symlink -/harness-symlink --backup-dir ~/my-backups/project-a -``` - -### `/harness-delete` — harness symlink/파일 제거 - -프로젝트 루트에서 harness 항목을 제거합니다. - -```bash -/harness-delete # symlink만 제거 (기본) -/harness-delete --include-real # 실제 파일/디렉토리도 삭제 (구버전 마이그레이션용) -/harness-delete --yes # 비대화식 -``` - -`--include-real`은 구버전(파일이 symlink가 아닌 실제 파일로 존재)에서 신버전으로 전환할 때 사용합니다. -`--yes`는 `--include-real`의 "PERMANENTLY delete" 확인까지 건너뛰므로 둘을 함께 쓰기 전에 삭제 대상을 확인하세요. - -### `/harness-migrate` — v0.2.x → v0.3+ 스크립트 위치 이전 +### `/harness-migrate` — 구조 마이그레이션 + 설치본 refresh -v0.2.x에서 backup dir에 있던 `clone.sh`, `symlink.sh`, `delete.sh`를 프로젝트 루트로 이전합니다. +구버전 구조를 최신으로 옮기고, 설치된 훅·스킬·규칙을 현재 템플릿으로 갱신합니다. ```bash /harness-migrate @@ -465,24 +432,6 @@ v0.2.x에서 backup dir에 있던 `clone.sh`, `symlink.sh`, `delete.sh`를 프 > 그래서 플래그 없이는 안내 한 줄만 내고, `--yes` 단독으로도 채택하지 않습니다 — 잃는 마커 수와 > 그 결과를 먼저 보여주고 확인을 받습니다. -### `/harness-upgrade` — v0.3.x → v0.4+ 원스텝 전환 - -실제 파일로 존재하는 harness 아티팩트를 symlink 구조로 일괄 전환합니다. - -```bash -/harness-upgrade # backup dir 자동 탐지 -/harness-upgrade --backup-dir ~/backups/p # 경로 명시 (tilde 지원) -/harness-upgrade --yes # 비대화식 -``` - -내부 동작 순서: -1. backup dir 확인 (없으면 clone 먼저 실행) -2. project → backup 동기화 (`/harness-clone`) -3. 실제 파일/디렉토리 목록 표시 + 확인 -4. 실제 항목 삭제 (`.harness/backup.json` 내용 보존) -5. `.harness/backup.json` 복원 -6. symlink 생성 (`/harness-symlink`) - --- ## task 관리 (팀원·task별) @@ -616,140 +565,11 @@ $ /harness-task list --- -## 스크립트 3종 사용법 - -`harness-team init` 실행 시 **프로젝트 루트에** 설치되는 세 스크립트입니다. 백업 클론 폴더(`BACKUP_DIR`)는 프로젝트와 같은 레벨의 형제 폴더 아래에 위치하며, 그 경로는 생성 시점에 각 스크립트에 박혀 들어갑니다. - -### 설치 구조 - -``` -~/work/ - ├── project-a/ ← 실제 작업 디렉토리 (CWD) - │ ├── CLAUDE.md - │ ├── .claude/ - │ ├── clone.sh ← 스크립트는 프로젝트 루트에 위치 - │ ├── symlink.sh - │ ├── delete.sh - │ └── .harness/backup.json ← 백업 경로 기억 - │ - └── harness-backup/ ← 형제 레벨 상위 폴더 (이름 사용자 지정) - └── project-a/ ← BACKUP_DIR (clone.sh가 여기에 복사) -``` - -실제 사용 방식 (프로젝트 루트에서 실행): - -```bash -cd ~/work/project-a -./clone.sh # project-a → BACKUP_DIR 로 병합 복사 (newer-wins, 백업 파일 삭제 없음) -./symlink.sh # BACKUP_DIR 의 자산을 project-a 로 symlink -./delete.sh # BACKUP_DIR 을 가리키는 symlink만 제거 -``` - -### 안전 원칙 — 어느 스크립트도 파괴적이지 않음 - -- **백업 디렉토리는 어떤 스크립트도 삭제하지 않습니다.** 원본 파일들(CLAUDE.md, docs 등)은 항상 보존됩니다. -- **프로젝트 쪽 실파일도 함부로 지우지 않습니다.** symlink만 다룹니다. -- 실파일을 정말 제거하려면 인터랙티브 CLI(`harness-team delete --include-real`)를 사용하세요. - -### init 시 설정 - -`harness-team init` 실행 시 상위 폴더명을 입력받습니다(기본값: `harness-backup`): - -``` -$ harness-team init -harness-team init → /Users/chad/work/project-a - stack: react-native (rn) - -Backup clone parent folder (sibling of project, holds clone.sh/symlink.sh/delete.sh)? [harness-backup] my-backups - backup clone dir: /Users/chad/work/my-backups/project-a -... -``` - -- 입력한 이름대로 `../<입력값>/<프로젝트명>/` 디렉토리가 자동 생성됩니다. -- 경로는 `.harness/backup.json` 에 저장되어 이후 `doctor` 등에서 재사용됩니다. -- 비대화(`--yes`) 실행 시 `--backup-parent=` 으로 지정할 수 있습니다. - -### 언제 쓰나? - -플러그인만 쓰는 경우 이 스크립트는 **불필요**합니다. 다음과 같은 경우에만 유용: - -- 같은 팀이 여러 관련 프로젝트(예: `web-app`, `mobile-app`, `admin`)를 운영 -- 프로젝트 스냅샷/백업을 별도 디렉토리로 주기적으로 떠두고 싶음 -- 여러 프로젝트가 공통 harness 내용을 공유하고 한쪽의 개선을 다른 쪽으로 역동기화 - -### `symlink.sh` — BACKUP_DIR → 프로젝트로 심볼릭 링크 - -**용도**: BACKUP_DIR의 자산을 프로젝트 루트에 symlink로 연결. - -링크 대상(ITEMS): -`CLAUDE.md`, `AGENTS.md`, `.claude`, `.cursor`, `.codex`, `docs`, `.harness` - -**사용 예**: -```bash -cd ~/work/project-a -./symlink.sh -# 출력: -# linked: CLAUDE.md -> /Users/chad/work/harness-backup/project-a/CLAUDE.md -# linked: .claude -> /Users/chad/work/harness-backup/project-a/.claude -# ... -``` - -**안전장치**: -- 이미 BACKUP_DIR로 링크된 항목은 건너뜀 -- 다른 곳을 가리키는 symlink는 즉시 교체 (실파일 손실 없음) -- 프로젝트 쪽 실파일이 있을 때: - - 백업본과 **byte-identical** 이면 symlink로 교체 - - **다르면 건드리지 않고 skip** — `./clone.sh`로 백업에 병합 후 재실행하라고 안내 - -### `clone.sh` — 프로젝트 → BACKUP_DIR 병합 복사 - -**용도**: 프로젝트 현재 내용을 BACKUP_DIR에 **병합 복사**. `rsync -a --update` 기반(newer-wins, **`--delete` 없음**), 백업에만 있는 파일은 보존됩니다. - -**사용 예**: -```bash -cd ~/work/project-a -./clone.sh -# 출력: -# merged dir: .claude -> /Users/chad/work/harness-backup/project-a/.claude -# copied (newer): CLAUDE.md -> /Users/chad/work/harness-backup/project-a/CLAUDE.md -# ... -``` - -### `delete.sh` — 링크 제거 (init의 정반대) - -**용도**: `symlink.sh`가 만든 링크만 제거. 백업 디렉토리와 프로젝트의 실파일은 모두 보존합니다. - -**사용 예**: -```bash -cd ~/work/project-a -./delete.sh -# 출력: -# removed: CLAUDE.md (backup symlink) -# removed: .claude (backup symlink) -# skip: docs (real file/dir — use 'harness-team delete --include-real' to remove) -``` - -**안전장치**: -- BACKUP_DIR을 가리키는 symlink만 제거 (`"$target" == "$BACKUP_DIR" || "$target" == "$BACKUP_DIR"/*` 체크 — 경로 경계까지 봐서 `project-a`가 `project-ab`를 잡지 않음) -- 다른 곳을 가리키는 symlink는 건드리지 않음 -- 실파일/디렉터리는 skip + 안내. 실제로 지우려면 인터랙티브 CLI(`harness-team delete --include-real`) 사용 - -### `/harness-sync` vs `./symlink.sh` — 한 줄 요약 - -| | `/harness-sync` | `./symlink.sh` | -|---|---|---| -| 대상 | 같은 프로젝트 내부 (`.cursor/rules` 미러) | 외부 중앙 harness repo | -| 목적 | 플러그인이 설치한 구조의 무결성 유지 | 여러 프로젝트가 하나의 harness를 공유 | -| 주요 작업 | `.cursor/rules` 미러링 + post-commit 훅 재설치 (에이전트 파일 갱신은 `init` 재실행) | 중앙의 `AGENTS.md`, `CLAUDE.md`, `.claude`, `docs` 등을 현재 프로젝트로 심볼릭 링크 | -| 언제 | rules 수정 후 | 중앙 harness를 새 프로젝트에 적용할 때 | - ---- - ## 설치 결과물 -설치되는 파일과 task 계약은 scaffold 되는 `AGENTS.md`의 **작업 프로토콜** 및 `templates/`를 확인합니다. 개인 상태 파일은 `.harness/active.json`에 보관됩니다. 반면 백업 클론 폴더 경로를 기억하는 `.harness/backup.json`은 팀이 공유하는 설정이므로 commit을 권장합니다. 관리 절의 마지막 렌더 해시를 담는 `.harness/render-state.json`도 **팀 상태이므로 반드시 commit 합니다** — 커밋하지 않으면 팀원이 clone한 뒤 첫 `init`에서 판정 근거가 없어 관리 절의 사용자 편집을 한 번 덮어씁니다. 이 파일의 `harnessVersion`은 마지막으로 `init`을 적용한 하네스 버전이고(`version`은 파일 스키마 버전), `doctor`가 실행 중인 CLI와 비교합니다. 타임스탬프는 남기지 않으므로 같은 버전으로 `init`을 다시 돌려도 diff가 생기지 않습니다. +설치되는 파일과 task 계약은 scaffold 되는 `AGENTS.md`의 **작업 프로토콜** 및 `templates/`를 확인합니다. 개인 상태 파일은 `.harness/active.json`에 보관됩니다. 관리 절의 마지막 렌더 해시를 담는 `.harness/render-state.json`도 **팀 상태이므로 반드시 commit 합니다** — 커밋하지 않으면 팀원이 clone한 뒤 첫 `init`에서 판정 근거가 없어 관리 절의 사용자 편집을 한 번 덮어씁니다. 이 파일의 `harnessVersion`은 마지막으로 `init`을 적용한 하네스 버전이고(`version`은 파일 스키마 버전), `doctor`가 실행 중인 CLI와 비교합니다. 타임스탬프는 남기지 않으므로 같은 버전으로 `init`을 다시 돌려도 diff가 생기지 않습니다. -자동으로 `.gitignore`에 추가되는 항목(`.harness/`를 통째로 무시하지 않습니다 — `backup.json`·`cursor-mirror.json`·`render-state.json`은 팀 상태): +자동으로 `.gitignore`에 추가되는 항목(`.harness/`를 통째로 무시하지 않습니다 — `cursor-mirror.json`·`render-state.json`은 팀 상태): - `.claude/settings.local.json` (개인 권한 오버라이드) - `.harness/active.json` (개인 활성 task 상태) - `.harness/config.json` (개인 docs 사용자명) @@ -798,7 +618,7 @@ Claude Code 도구 관측은 원문을 보존하지 않는 로컬 JSONL만 `.har | 문서 | 설명 | |---|---| | [index.html](docs/index.html) | docs 전체 색인 — 가이드·릴리스 노트·버전별 스냅샷 | -| [harness-overview.html](docs/harness-overview.html) | 플러그인 전체 아키텍처 다이어그램 — 에이전트 연결, symlink 구조, 명령 흐름. 소스 변경 뒤 `npm run docs:generate`로 갱신하는 생성 산출물 | +| [harness-overview.html](docs/harness-overview.html) | 플러그인 전체 아키텍처 다이어그램 — 에이전트 연결, 명령 흐름. 소스 변경 뒤 `npm run docs:generate`로 갱신하는 생성 산출물 | | [harness-task-guide.html](docs/harness-task-guide.html) | init 이후 개발자용 — 첫 task를 만들어 닫을 때까지의 실제 절차 | | [harness-rubric-guide.html](docs/harness-rubric-guide.html) | 루브릭 평가(D6) 가이드 — finding 스키마, 5개 검증 프레이밍, 마커 계약과 `verify` 증거 게이트 | | [harness-workflow-simulation.html](docs/harness-workflow-simulation.html) | task 워크플로우 시나리오 시뮬레이션 — new → done 흐름 단계별 인터랙티브 | diff --git a/bin/harness-team.mjs b/bin/harness-team.mjs index ae83d145..49bc654c 100755 --- a/bin/harness-team.mjs +++ b/bin/harness-team.mjs @@ -4,7 +4,6 @@ import { readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { resolveInvocation } from '../src/cli-args.mjs'; import { runInit } from '../src/commands/init.mjs'; -import { runBackup } from '../src/commands/backup.mjs'; import { runSync } from '../src/commands/sync.mjs'; import { runDoctor } from '../src/commands/doctor.mjs'; import { runStack } from '../src/commands/stack.mjs'; @@ -12,11 +11,7 @@ import { runScope } from '../src/commands/scope.mjs'; import { runConfig } from '../src/commands/config.mjs'; import { runDiagram } from '../src/commands/diagram.mjs'; import { runTask, runList, runDone, runHandoffAuto, runRetro } from '../src/commands/task.mjs'; -import { runClone } from '../src/commands/clone.mjs'; -import { runSymlink } from '../src/commands/symlink.mjs'; -import { runDelete } from '../src/commands/delete.mjs'; import { runMigrate } from '../src/commands/migrate.mjs'; -import { runUpgrade } from '../src/commands/upgrade.mjs'; import { runRelease } from '../src/commands/release.mjs'; import { runSessionContext } from '../src/commands/session-context.mjs'; import { runContext } from '../src/commands/context.mjs'; @@ -65,12 +60,7 @@ async function main() { switch (cmd) { case 'init': return runInit(ctx); - case 'backup': return runBackup(ctx); - case 'clone': return runClone(ctx); - case 'symlink': return runSymlink(ctx); - case 'delete': return runDelete(ctx); case 'migrate': return runMigrate(ctx); - case 'upgrade': return runUpgrade(ctx); case 'sync': return runSync(ctx); case 'doctor': return runDoctor(ctx); case 'stack': return runStack(ctx); diff --git a/commands/harness-clone.md b/commands/harness-clone.md deleted file mode 100644 index ddbd792a..00000000 --- a/commands/harness-clone.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -description: project → backup dir 동기화 (merge, newer-wins) -phase: Backup -argument-hint: [--yes] [--target ] ---- - -```bash -node "${CLAUDE_PLUGIN_ROOT}/bin/harness-team.mjs" clone $ARGUMENTS -``` diff --git a/commands/harness-delete.md b/commands/harness-delete.md deleted file mode 100644 index 51869521..00000000 --- a/commands/harness-delete.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -description: project에서 harness symlink 제거 -phase: Cleanup -argument-hint: '[--include-real] [--yes] [--backup-dir ] [--target ]' ---- - -```bash -node "${CLAUDE_PLUGIN_ROOT}/bin/harness-team.mjs" delete $ARGUMENTS -``` - -기본은 백업 dir을 가리키는 symlink만 제거한다. `--include-real`은 실파일·디렉터리(`docs/`·`.harness/` 포함)까지 -**영구 삭제**하며, `--yes`는 그 "PERMANENTLY delete" 확인까지 건너뛴다 — 두 플래그를 함께 넘기기 전에 -사용자에게 삭제 대상을 보여주고 명시적으로 확인받는다. diff --git a/commands/harness-init.md b/commands/harness-init.md index 1526e7ac..aa21a5a3 100644 --- a/commands/harness-init.md +++ b/commands/harness-init.md @@ -28,8 +28,8 @@ deep-merge하며, hooks·rules·skills·`docs/` seed는 이미 있으면 건너 기존 설치본에서는 첫 실행이 stock으로 간주해 한 번 교체하며, 그 1회는 `migrate`가 원본을 백업하고 diff로 미리 알려 줍니다(아래 `harness-team migrate` 참고). -Claude의 Bash는 TTY가 아니라 CLI의 readline 프롬프트(사용자명·백업 폴더·gitignore·적용 확인)에 답할 수 -없습니다. 그래서 아래 Step 0~2에서 답을 먼저 받아 **플래그로 넘깁니다** — 플래그 없이 `init`만 실행하지 마세요. +Claude의 Bash는 TTY가 아니라 CLI의 readline 프롬프트(사용자명·적용 확인)에 답할 수 +없습니다. 그래서 아래 Step 0~1에서 답을 먼저 받아 **플래그로 넘깁니다** — 플래그 없이 `init`만 실행하지 마세요. `--stack`을 주지 않으면 자동 감지하며, React Native/Expo 전용 rules 4종은 유효 stack이 RN 계열일 때만 설치됩니다. 감지와 다른 `--stack`은 `.harness/render-state.json`에 고정되어 이후 플래그 없는 `init`·`doctor`·`migrate`도 그 스택을 씁니다 — 감지된 id를 다시 주면(`--stack node` 등) 고정이 풀립니다. @@ -49,72 +49,11 @@ Claude의 Bash는 TTY가 아니라 CLI의 readline 프롬프트(사용자명· **예** 선택 시: init 실행 완료 후, 기존 커스텀 내용을 생성된 `CLAUDE.md`의 `` ~ `` 사이에 추가합니다. -**Step 1 — 백업 폴더 설정** +**Step 1 — 실행** -`AskUserQuestion` 툴로 다음을 물어보세요: +하네스 전용 `.gitignore` 항목(`.claude/settings.local.json`, `.harness/active.json`, `.harness/config.json`, +`.harness/observability/`, `.harness/backup/`, 사용자 handoff)은 항상 추가된다. -> 백업 스크립트(clone.sh / symlink.sh / delete.sh)를 어디에 저장할까요? -> -> **옵션 1 — 상위 폴더만 입력** (권장) -> 프로젝트 형제 위치에 `<상위폴더>/<프로젝트명>/` 형태로 생성됩니다. -> 예: `harness-backup` → `../harness-backup/my-app/` -> -> **옵션 2 — 전체 경로 직접 입력** -> 원하는 경로를 그대로 지정합니다. -> 예: `~/backup/team-backups/my-app` -> -> **옵션 3 — 기본값 사용** -> `../harness-backup/<프로젝트명>/` 에 자동 생성됩니다. (Enter) - -**Step 2 — AI 도구 .gitignore 항목 추가 여부 확인** - -`AskUserQuestion` 툴로 다음을 물어보세요: - -> .gitignore에 AI 도구 관련 항목을 추가할까요? -> -> 추가될 항목: (아래 정본 목록을 그대로 보여준다) -> -> **예** — 추가합니다 -> **아니오** — 건너뜁니다 (하네스 전용 항목만 추가됨) - -추가될 항목의 정본은 `${CLAUDE_PLUGIN_ROOT}/src/harness.mjs`의 `AI_GITIGNORE_ENTRIES`다 — 묻기 전에 그 배열을 -읽어 그대로 보여준다. 손으로 옮겨 적은 목록은 드리프트한다(예전 문서는 실제로 추가되지 않는 `docs/`를 -보여줬고, 실제로 추가되는 `.codex`는 빠져 있었다). 하네스 전용 항목(`.claude/settings.local.json`, -`.harness/active.json`, `.harness/config.json`, `.harness/observability/`)은 답과 무관하게 항상 추가된다 — -`.harness/backup.json`은 팀 공유 설정이라 무시하지 않는다. - -**Step 3 — 답변에 따라 실행** - -백업 폴더 답변(Step 1)과 gitignore 답변(Step 2)을 조합해서 실행합니다. - -`--gitignore-ai` 플래그: gitignore에 AI 항목 추가 / `--no-gitignore-ai`: 건너뜀 - -- 옵션 1 (상위 폴더 입력 → ``) + gitignore 추가: -```bash -node "${CLAUDE_PLUGIN_ROOT}/bin/harness-team.mjs" init --backup-parent "" --gitignore-ai $ARGUMENTS -``` - -- 옵션 1 + gitignore 건너뜀: -```bash -node "${CLAUDE_PLUGIN_ROOT}/bin/harness-team.mjs" init --backup-parent "" --no-gitignore-ai $ARGUMENTS -``` - -- 옵션 2 (전체 경로 → ``) + gitignore 추가: -```bash -node "${CLAUDE_PLUGIN_ROOT}/bin/harness-team.mjs" init --backup-dir "" --gitignore-ai $ARGUMENTS -``` - -- 옵션 2 + gitignore 건너뜀: -```bash -node "${CLAUDE_PLUGIN_ROOT}/bin/harness-team.mjs" init --backup-dir "" --no-gitignore-ai $ARGUMENTS -``` - -- 옵션 3 (기본값) + gitignore 추가: -```bash -node "${CLAUDE_PLUGIN_ROOT}/bin/harness-team.mjs" init --yes --gitignore-ai $ARGUMENTS -``` - -- 옵션 3 + gitignore 건너뜀: ```bash node "${CLAUDE_PLUGIN_ROOT}/bin/harness-team.mjs" init --yes $ARGUMENTS ``` diff --git a/commands/harness-migrate.md b/commands/harness-migrate.md index 91e1f43f..afcc5664 100644 --- a/commands/harness-migrate.md +++ b/commands/harness-migrate.md @@ -6,7 +6,7 @@ argument-hint: [--yes] [--adopt-reviews] [--target ] 이 명령은 두 가지를 한다. -1. **구조 마이그레이션** — backup dir 스크립트 → project root, task 구조(pre-0.6→0.6, +1. **구조 마이그레이션** — task 구조(pre-0.6→0.6, 0.6→0.7 artifact.md 분리), task 인덱스 라벨(active → open), SessionStart·boundary 훅 배선. 2. **템플릿 refresh** — 설치된 `.claude/hooks`(6) · `.claude/skills`(3) · `.claude/rules`(4)를 최신 템플릿으로 갱신한다. `init`은 이미 있는 파일을 건너뛰므로(`skipExisting`), **수정된 diff --git a/commands/harness-symlink.md b/commands/harness-symlink.md deleted file mode 100644 index 8d5e314f..00000000 --- a/commands/harness-symlink.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -description: backup dir → project symlink 생성/교체 -phase: Backup -argument-hint: [--yes] [--target ] ---- - -```bash -node "${CLAUDE_PLUGIN_ROOT}/bin/harness-team.mjs" symlink $ARGUMENTS -``` diff --git a/commands/harness-upgrade.md b/commands/harness-upgrade.md deleted file mode 100644 index f5eb6e6b..00000000 --- a/commands/harness-upgrade.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -description: 구버전 구조(실제 파일)를 신버전(symlink)으로 원스텝 마이그레이션 -phase: Migration -argument-hint: '[--yes] [--backup-dir ] [--target ]' ---- - -```bash -node "${CLAUDE_PLUGIN_ROOT}/bin/harness-team.mjs" upgrade $ARGUMENTS -``` - -내부 순서: 백업 dir 동기화(clone) → 실파일 목록 확인 → `delete --include-real --yes`(영구 삭제) → symlink 재생성. -확인 프롬프트는 한 번뿐이고 `--yes`는 그것마저 건너뛴다 — 비대화식으로 돌리기 전에 백업 dir이 최신인지 -사용자와 확인한다. diff --git a/docs/diagrams/harness-overview/architecture.mmd b/docs/diagrams/harness-overview/architecture.mmd index 695ef6c0..bab05087 100644 --- a/docs/diagrams/harness-overview/architecture.mmd +++ b/docs/diagrams/harness-overview/architecture.mmd @@ -4,15 +4,13 @@ graph TB subgraph CMDS["Commands Layer"] direction LR INIT["init (신규·재실행)"] & SYNC["sync"] - CLONE["clone"] & SYM["symlink"] & DEL["delete"] & BACKUP["backup"] DOCTOR["doctor"] & TASK["task · list · done\nhandoff · retro"] & SESS["session-context"] - MIG["migrate"] & UPG["upgrade"] & REL["release"] + MIG["migrate"] & REL["release"] end subgraph ORCH["Orchestration"] direction LR HARNESS["harness.mjs\nplanChanges · applyChanges\ncopyStaticAssets (renders 2 agent files)"] - BACKUPDIR["backup-dir.mjs\nresolveBackupDir\n4-priority resolution"] end subgraph UTIL["Utilities"] @@ -33,13 +31,10 @@ graph TB HOOKS_T[".claude/hooks/"] RULES_T[".claude/rules/"] SKILLS_T[".claude/skills/"] - SCRIPTS["clone.sh\nsymlink.sh\ndelete.sh"] end BIN --> CMDS INIT & SYNC --> HARNESS - CLONE & SYM & DEL & BACKUP & MIG & UPG --> BACKUPDIR - DOCTOR --> BACKUPDIR TASK --> MEMBER DOCTOR & TASK & REL --> OBS DOCTOR --> SESS diff --git a/docs/diagrams/harness-overview/backup-layout.mmd b/docs/diagrams/harness-overview/backup-layout.mmd deleted file mode 100644 index 069752dc..00000000 --- a/docs/diagrams/harness-overview/backup-layout.mmd +++ /dev/null @@ -1,22 +0,0 @@ -graph LR - subgraph W["~/work/"] - subgraph P["my-project/ (프로젝트)"] - CM["CLAUDE.md"] - CL[".claude/"] - HA[".harness/\nbackup.json"] - SCR["clone.sh\nsymlink.sh\ndelete.sh"] - end - subgraph B["harness-backup/my-project/ (백업)"] - B_CM["CLAUDE.md"] - B_CL[".claude/"] - B_DOCS["docs/"] - B_SCR["clone.sh\nsymlink.sh\ndelete.sh"] - end - end - - SCR -. "clone.sh\nnewer-wins rsync" .-> B_CM & B_CL & B_DOCS - B_SCR -. "symlink.sh\nbackup→project" .-> CM - - style P fill:#1a2035,stroke:#2e3350 - style B fill:#231a10,stroke:#2e3350 - style W fill:#1a1d27,stroke:#2e3350 diff --git a/docs/diagrams/harness-overview/backup-resolution.mmd b/docs/diagrams/harness-overview/backup-resolution.mmd deleted file mode 100644 index 023b3f8e..00000000 --- a/docs/diagrams/harness-overview/backup-resolution.mmd +++ /dev/null @@ -1,21 +0,0 @@ -flowchart TD - S([resolveBackupDir 호출]) --> F1{"--backup-dir\n플래그?"} - F1 -- Yes --> R1[/"CLI 플래그 경로"/] - F1 -- No --> F2{".harness/backup.json\n존재?"} - F2 -- Yes --> F2a{"dir 필드\n있음?"} - F2a -- Yes --> R2[/"backup.json dir 경로"/] - F2a -- No --> F2b{"parent + name\n있음?"} - F2b -- Yes --> R3[/"path.join(parent, name)"/] - F2b -- No --> F3 - F2 -- No --> F3{"기존 symlink\n역추적?"} - F3 -- Yes --> R4[/"symlink target 경로"/] - F3 -- No --> F4{"../harness-backup/\nprojectName/?"} - F4 -- Yes --> R5[/"자동 탐지 경로"/] - F4 -- No --> R6[/"null 반환"/] - - style R1 fill:#0e3d30,stroke:#4df0aa - style R2 fill:#0e3d30,stroke:#4df0aa - style R3 fill:#0e3d30,stroke:#4df0aa - style R4 fill:#0e3d30,stroke:#4df0aa - style R5 fill:#0e3d30,stroke:#4df0aa - style R6 fill:#3d1a1a,stroke:#f05050 diff --git a/docs/diagrams/harness-overview/workflow.mmd b/docs/diagrams/harness-overview/workflow.mmd index 530aab4b..e4e82588 100644 --- a/docs/diagrams/harness-overview/workflow.mmd +++ b/docs/diagrams/harness-overview/workflow.mmd @@ -11,9 +11,6 @@ sequenceDiagram CLI->>Cmd: init.run(cwd, flags) Cmd->>H: detectStack(cwd) H-->>Cmd: stack, language, packageManager - Cmd->>User: 백업 폴더명 확인 (prompt) - User-->>Cmd: "harness-backup" - Cmd->>FS: mkdir(backupDir) Cmd->>H: planChanges(ctx) H->>H: render(AGENTS/CLAUDE.md.hbs) H-->>Cmd: changes[] @@ -25,7 +22,6 @@ sequenceDiagram Cmd->>H: copyStaticAssets H->>FS: hooks · rules · skills · docs 복사 H->>FS: .claude/rules → .cursor/rules 미러링 - Cmd->>FS: clone.sh · symlink.sh · delete.sh 기록 Cmd-->>User: ✅ 완료 end diff --git a/docs/harness-overview.html b/docs/harness-overview.html index b0e31b83..91d28a31 100644 --- a/docs/harness-overview.html +++ b/docs/harness-overview.html @@ -190,7 +190,6 @@ Workflow Task Core - Backup Template 파일 구조 @@ -476,15 +475,13 @@

Harness Aijient Team

subgraph CMDS["Commands Layer"] direction LR INIT["init (신규·재실행)"] & SYNC["sync"] - CLONE["clone"] & SYM["symlink"] & DEL["delete"] & BACKUP["backup"] DOCTOR["doctor"] & TASK["task · list · done\nhandoff · retro"] & SESS["session-context"] - MIG["migrate"] & UPG["upgrade"] & REL["release"] + MIG["migrate"] & REL["release"] end subgraph ORCH["Orchestration"] direction LR HARNESS["harness.mjs\nplanChanges · applyChanges\ncopyStaticAssets (renders 2 agent files)"] - BACKUPDIR["backup-dir.mjs\nresolveBackupDir\n4-priority resolution"] end subgraph UTIL["Utilities"] @@ -505,13 +502,10 @@

Harness Aijient Team

HOOKS_T[".claude/hooks/"] RULES_T[".claude/rules/"] SKILLS_T[".claude/skills/"] - SCRIPTS["clone.sh\nsymlink.sh\ndelete.sh"] end BIN --> CMDS INIT & SYNC --> HARNESS - CLONE & SYM & DEL & BACKUP & MIG & UPG --> BACKUPDIR - DOCTOR --> BACKUPDIR TASK --> MEMBER DOCTOR & TASK & REL --> OBS DOCTOR --> SESS @@ -654,36 +648,12 @@

Harness Aijient Team

활성 task의 spec/plan 다이어그램을 하네스 규약(경로·자립형 inline SVG·artifact 기록)대로 생성하거나 갱신 Workflow - - /harness-clone - commands/harness-clone.md - project → backup dir 동기화 (merge, newer-wins) - Backup - - - /harness-symlink - commands/harness-symlink.md - backup dir → project symlink 생성/교체 - Backup - - - /harness-delete - commands/harness-delete.md - project에서 harness symlink 제거 - Cleanup - /harness-migrate commands/harness-migrate.md 구버전 구조를 최신으로 마이그레이션 + 설치된 훅·스킬·규칙을 최신 템플릿으로 refresh — init은 기존 파일을 건너뛰므로 이 파일들의 템플릿 수정이 도달하는 유일한 경로 (AGENTS.md·CLAUDE.md 관리 절은 init이 렌더한다) Migration - - /harness-upgrade - commands/harness-upgrade.md - 구버전 구조(실제 파일)를 신버전(symlink)으로 원스텝 마이그레이션 - Migration - /harness-spec commands/harness-spec.md @@ -714,18 +684,6 @@

Harness Aijient Team

현재(또는 지정) task의 artifact.md에 학습/교정 내용을 append. CLAUDE.md 자기개선 루프 정책 실행. Workflow - - /harness-release - commands/harness-release.md - 4개 매니페스트(package.json/.claude-plugin/plugin.json/.claude-plugin/marketplace.json/.codex-plugin/plugin.json) 동시 bump + 캐시/마켓플레이스/installed_plugins.json 동기화. 휴먼 에러(manifest 누락·버전 불일치) 차단. - Release - - - /harness-sim - commands/harness-sim.md - 설치된 하네스와 에이전트 워크플로우 스킬이 진짜 작동하는지 실제 claude -p 세션으로 검증 (L5 agent-in-the-loop) + 날짜 리포트 (harness-sim 스킬 위임) - Simulation - /harness-unittest commands/harness-unittest.md @@ -1156,9 +1114,6 @@

commands/*.md

CLI->>Cmd: init.run(cwd, flags) Cmd->>H: detectStack(cwd) H-->>Cmd: stack, language, packageManager - Cmd->>User: 백업 폴더명 확인 (prompt) - User-->>Cmd: "harness-backup" - Cmd->>FS: mkdir(backupDir) Cmd->>H: planChanges(ctx) H->>H: render(AGENTS/CLAUDE.md.hbs) H-->>Cmd: changes[] @@ -1170,7 +1125,6 @@

commands/*.md

Cmd->>H: copyStaticAssets H->>FS: hooks · rules · skills · docs 복사 H->>FS: .claude/rules → .cursor/rules 미러링 - Cmd->>FS: clone.sh · symlink.sh · delete.sh 기록 Cmd-->>User: ✅ 완료 end @@ -1320,68 +1274,6 @@

commands/*.md

공유 코어 — AGENTS.md 수정 하나가 @import(Claude)와 네이티브(Codex·Cursor)로 전파

- -
-
Backup 디렉토리 구조
-

resolveBackupDir 우선순위 플로우 및 clone/symlink 흐름

-
-
- flowchart TD - S([resolveBackupDir 호출]) --> F1{"--backup-dir\n플래그?"} - F1 -- Yes --> R1[/"CLI 플래그 경로"/] - F1 -- No --> F2{".harness/backup.json\n존재?"} - F2 -- Yes --> F2a{"dir 필드\n있음?"} - F2a -- Yes --> R2[/"backup.json dir 경로"/] - F2a -- No --> F2b{"parent + name\n있음?"} - F2b -- Yes --> R3[/"path.join(parent, name)"/] - F2b -- No --> F3 - F2 -- No --> F3{"기존 symlink\n역추적?"} - F3 -- Yes --> R4[/"symlink target 경로"/] - F3 -- No --> F4{"../harness-backup/\nprojectName/?"} - F4 -- Yes --> R5[/"자동 탐지 경로"/] - F4 -- No --> R6[/"null 반환"/] - - style R1 fill:#0e3d30,stroke:#4df0aa - style R2 fill:#0e3d30,stroke:#4df0aa - style R3 fill:#0e3d30,stroke:#4df0aa - style R4 fill:#0e3d30,stroke:#4df0aa - style R5 fill:#0e3d30,stroke:#4df0aa - style R6 fill:#3d1a1a,stroke:#f05050 -
-
-

resolveBackupDir 4단계 우선순위 — CLI 플래그 → config 파일 → symlink 역추적 → 자동 탐지

- -
-
-
- graph LR - subgraph W["~/work/"] - subgraph P["my-project/ (프로젝트)"] - CM["CLAUDE.md"] - CL[".claude/"] - HA[".harness/\nbackup.json"] - SCR["clone.sh\nsymlink.sh\ndelete.sh"] - end - subgraph B["harness-backup/my-project/ (백업)"] - B_CM["CLAUDE.md"] - B_CL[".claude/"] - B_DOCS["docs/"] - B_SCR["clone.sh\nsymlink.sh\ndelete.sh"] - end - end - - SCR -. "clone.sh\nnewer-wins rsync" .-> B_CM & B_CL & B_DOCS - B_SCR -. "symlink.sh\nbackup→project" .-> CM - - style P fill:#1a2035,stroke:#2e3350 - style B fill:#231a10,stroke:#2e3350 - style W fill:#1a1d27,stroke:#2e3350 -
-
-

백업 디렉토리 위치 및 clone/symlink 방향

-
-
-
AGENTS.md 코어 + thin 템플릿 구조
@@ -1484,11 +1376,6 @@

병합 마커 포맷

Command 설계 결정과 가정에 반박을 시도하는 적대적 read-only 외부 리뷰 — 절차·엔진 표는 harness-review와 동일, 리뷰 프롬프트만 교체 - - commands/harness-clone.md - Command - project → backup dir 동기화 (merge, newer-wins) - commands/harness-comptest.md Command @@ -1499,11 +1386,6 @@

병합 마커 포맷

Command 활성 task의 spec.md / plan.md의 모든 가정에 의문을 제기한다 — 엔진 인수를 주면 외부 read-only 검증자가 반론(D6) - - commands/harness-delete.md - Command - project에서 harness symlink 제거 - commands/harness-diagram.md Command @@ -1544,11 +1426,6 @@

병합 마커 포맷

Command 활성 task artifact의 Learnings 항목을 사용자 승인 후 .claude/rules/<slug>.md로 승격 — 유래 마커 부착·cursor 미러 재생성 - - commands/harness-release.md - Command - 4개 매니페스트(package.json/.claude-plugin/plugin.json/.claude-plugin/marketplace.json/.codex-plugin/plugin.json) 동시 bump + 캐시/마켓플레이스/installed_plugins.json 동기화. 휴먼 에러(manifest 누락·버전 불일치) 차단. - commands/harness-retro.md Command @@ -1564,11 +1441,6 @@

병합 마커 포맷

Command PR/MR 직전 spec·plan·artifact를 최종 갱신하고 다이어그램(옵트인)까지 정리한 뒤 준비 완료 상태를 보고 - - commands/harness-sim.md - Command - 설치된 하네스와 에이전트 워크플로우 스킬이 진짜 작동하는지 실제 claude -p 세션으로 검증 (L5 agent-in-the-loop) + 날짜 리포트 (harness-sim 스킬 위임) - commands/harness-simplifier.md Command @@ -1579,11 +1451,6 @@

병합 마커 포맷

Command 활성 task의 spec.md 초안을 Confluence·Figma·인터뷰 3소스에서 생성한다 (writer — 검증은 /harness-interview) - - commands/harness-symlink.md - Command - backup dir → project symlink 생성/교체 - commands/harness-sync.md Command @@ -1599,26 +1466,11 @@

병합 마커 포맷

Command JS/TS/React/RN 프로젝트에 Khorikov 원칙 기반 단위테스트를 작성한다 (스택 자동 감지 → GWT 강제 → 커버리지 → 검증) - - commands/harness-upgrade.md - Command - 구버전 구조(실제 파일)를 신버전(symlink)으로 원스텝 마이그레이션 - docs/diagrams/harness-overview/architecture.mmd Mermaid harness overview 아키텍처 다이어그램 원본 - - docs/diagrams/harness-overview/backup-layout.mmd - Mermaid - harness overview 아키텍처 다이어그램 원본 - - - docs/diagrams/harness-overview/backup-resolution.mmd - Mermaid - harness overview 아키텍처 다이어그램 원본 - docs/diagrams/harness-overview/core.mmd Mermaid @@ -1685,62 +1537,62 @@

병합 마커 포맷

다이어그램과 인벤토리를 한 번에 생성 - skills/harness-adversarial-review/SKILL.md + scripts/maintainer-skills/harness-codex-sim/SKILL.md Skill Codex용 harness workflow 지침 - skills/harness-adversarial-review/agents/openai.yaml + scripts/maintainer-skills/harness-codex-sim/agents/openai.yaml Config Codex skill 표시 메타데이터 - skills/harness-clone/SKILL.md + scripts/maintainer-skills/harness-release/SKILL.md Skill Codex용 harness workflow 지침 - skills/harness-clone/agents/openai.yaml + scripts/maintainer-skills/harness-release/agents/openai.yaml Config Codex skill 표시 메타데이터 - skills/harness-codex-sim/SKILL.md + scripts/maintainer-skills/harness-sim/SKILL.md Skill Codex용 harness workflow 지침 - skills/harness-codex-sim/agents/openai.yaml + scripts/maintainer-skills/harness-sim/agents/openai.yaml Config Codex skill 표시 메타데이터 - skills/harness-comptest/SKILL.md + skills/harness-adversarial-review/SKILL.md Skill Codex용 harness workflow 지침 - skills/harness-comptest/agents/openai.yaml + skills/harness-adversarial-review/agents/openai.yaml Config Codex skill 표시 메타데이터 - skills/harness-contrarian/SKILL.md + skills/harness-comptest/SKILL.md Skill Codex용 harness workflow 지침 - skills/harness-contrarian/agents/openai.yaml + skills/harness-comptest/agents/openai.yaml Config Codex skill 표시 메타데이터 - skills/harness-delete/SKILL.md + skills/harness-contrarian/SKILL.md Skill Codex용 harness workflow 지침 - skills/harness-delete/agents/openai.yaml + skills/harness-contrarian/agents/openai.yaml Config Codex skill 표시 메타데이터 @@ -1824,16 +1676,6 @@

병합 마커 포맷

Config Codex skill 표시 메타데이터 - - skills/harness-release/SKILL.md - Skill - Codex용 harness workflow 지침 - - - skills/harness-release/agents/openai.yaml - Config - Codex skill 표시 메타데이터 - skills/harness-retro/SKILL.md Skill @@ -1864,16 +1706,6 @@

병합 마커 포맷

Config Codex skill 표시 메타데이터 - - skills/harness-sim/SKILL.md - Skill - Codex용 harness workflow 지침 - - - skills/harness-sim/agents/openai.yaml - Config - Codex skill 표시 메타데이터 - skills/harness-simplifier/SKILL.md Skill @@ -1894,16 +1726,6 @@

병합 마커 포맷

Config Codex skill 표시 메타데이터 - - skills/harness-symlink/SKILL.md - Skill - Codex용 harness workflow 지침 - - - skills/harness-symlink/agents/openai.yaml - Config - Codex skill 표시 메타데이터 - skills/harness-sync/SKILL.md Skill @@ -1944,41 +1766,16 @@

병합 마커 포맷

Config Codex skill 표시 메타데이터 - - skills/harness-upgrade/SKILL.md - Skill - Codex용 harness workflow 지침 - - - skills/harness-upgrade/agents/openai.yaml - Config - Codex skill 표시 메타데이터 - - - src/backup-dir.mjs - Module - CLI가 공유하는 핵심 모듈 - src/cli-args.mjs Module CLI가 공유하는 핵심 모듈 - - src/commands/backup.mjs - Module - harness-team backup CLI 구현 - src/commands/boundary.mjs Module harness-team boundary CLI 구현 - - src/commands/clone.mjs - Module - harness-team clone CLI 구현 - src/commands/config.mjs Module @@ -1989,11 +1786,6 @@

병합 마커 포맷

Module harness-team context CLI 구현 - - src/commands/delete.mjs - Module - harness-team delete CLI 구현 - src/commands/diagram.mjs Module @@ -2064,11 +1856,6 @@

병합 마커 포맷

Module harness-team summary CLI 구현 - - src/commands/symlink.mjs - Module - harness-team symlink CLI 구현 - src/commands/sync.mjs Module @@ -2079,11 +1866,6 @@

병합 마커 포맷

Module harness-team task CLI 구현 - - src/commands/upgrade.mjs - Module - harness-team upgrade CLI 구현 - src/detect-stack.mjs Module @@ -2249,16 +2031,6 @@

병합 마커 포맷

Template 소비자 프로젝트 scaffold 템플릿 - - templates/clone.sh - Template - 소비자 프로젝트 scaffold 템플릿 - - - templates/delete.sh - Template - 소비자 프로젝트 scaffold 템플릿 - templates/docs/.gitkeep Template @@ -2274,11 +2046,6 @@

병합 마커 포맷

Template 소비자 프로젝트 scaffold 템플릿 - - templates/symlink.sh - Template - 소비자 프로젝트 scaffold 템플릿 - tests/active-json-validation.test.mjs Test @@ -2294,11 +2061,6 @@

병합 마커 포맷

Test 자동 회귀 검증 - - tests/backup-dir.test.mjs - Test - 자동 회귀 검증 - tests/boundary-checkpoint-write.test.mjs Test @@ -2354,11 +2116,6 @@

병합 마커 포맷

Test 자동 회귀 검증 - - tests/delete.test.mjs - Test - 자동 회귀 검증 - tests/detect-stack.test.mjs Test @@ -2854,11 +2611,6 @@

병합 마커 포맷

Test 자동 회귀 검증 - - tests/symlink.test.mjs - Test - 자동 회귀 검증 - tests/task-area.test.mjs Test diff --git a/docs/harness-overview.template.html b/docs/harness-overview.template.html index 1502a1e9..6b4a55ec 100644 --- a/docs/harness-overview.template.html +++ b/docs/harness-overview.template.html @@ -189,7 +189,6 @@ Workflow Task Core - Backup Template 파일 구조 @@ -863,27 +862,6 @@

commands/*.md

공유 코어 — AGENTS.md 수정 하나가 @import(Claude)와 네이티브(Codex·Cursor)로 전파

- -
-
Backup 디렉토리 구조
-

resolveBackupDir 우선순위 플로우 및 clone/symlink 흐름

-
-
- {{MERMAID:backup-resolution}} -
-
-

resolveBackupDir 4단계 우선순위 — CLI 플래그 → config 파일 → symlink 역추적 → 자동 탐지

- -
-
-
- {{MERMAID:backup-layout}} -
-
-

백업 디렉토리 위치 및 clone/symlink 방향

-
-
-
AGENTS.md 코어 + thin 템플릿 구조
diff --git a/docs/harness-workflow-simulation.html b/docs/harness-workflow-simulation.html index f129dc05..24d210ea 100644 --- a/docs/harness-workflow-simulation.html +++ b/docs/harness-workflow-simulation.html @@ -1087,11 +1087,7 @@

[task]-meta.json — 기계 상태 (0.16.0)

/harness-doctor무결성 점검 — 파일·훅·CLI PATH·외부 도구상황별 설치 직후·뭔가 이상할 때 /harness-observe관측 로그(.harness/observability) 스코어카드·트립와이어 판정 — read-only, 발화 시 exit 1 (0.26.0)상황별 관측 로그가 쌓인 소비자 프로젝트에서 /harness-syncsymlink 재생성 + .cursor/rules ↔ .claude/rules 동기화상황별 - /harness-cloneproject → backup dir 동기화 (merge, newer-wins)상황별 backup dir 운용 시 - /harness-symlinkbackup dir → project symlink 생성·교체상황별 backup dir 운용 시 - /harness-deleteproject에서 harness symlink 제거상황별 /harness-migrate구버전 구조 마이그레이션 (task 구조·artifact 분리)상황별 버전 업 직후 - /harness-upgrade구버전(실제 파일) → 신버전(symlink) 원스텝 마이그레이션상황별 버전 업 직후 /harness-release4개 매니페스트 동시 bump + 캐시·마켓플레이스 동기화메인테이너 이 저장소 릴리스 시 /harness-sim실제 claude -p 세션으로 하네스·스킬 동작 검증 (L5 agent-in-the-loop) + 날짜 리포트상황별 claude CLI 필요 커맨드 없는 스킬 (Codex 전용) diff --git a/docs/hslee/remove-backup-cluster/remove-backup-cluster-artifact.md b/docs/hslee/remove-backup-cluster/remove-backup-cluster-artifact.md new file mode 100644 index 00000000..08246f58 --- /dev/null +++ b/docs/hslee/remove-backup-cluster/remove-backup-cluster-artifact.md @@ -0,0 +1,38 @@ +# remove-backup-cluster — Artifact + +*최종 결과물과 학습 내용을 기록한다.* + +## 결과 +- 2026-10-05 제거·이동 적용. 명령 25 → 19, 스킬 27 → 20. 29파일 +85/−1004. +- 검증(메인 세션 재실행): `npm run test` 1056 중 pass 1055 · fail 0 · skip 1, `npm run docs:check` 최신. 임시 git 저장소에 `init --yes` → + 백업·gitignore 질문 없음, `backup.json` 없음, doctor 경고 1건은 Codex 훅 신뢰 안내(환경, 무관). +- 소비자 영향: 기존 `../harness-backup//`과 `.harness/backup.json`은 그대로 남는다(고아, 무해). AI 파일을 gitignore한 프로젝트 + (job-scraper)는 백업 클론이 저장소 밖 유일 사본일 수 있다 — 그 프로젝트에서 원할 때 gitignore를 풀고 커밋하거나 직접 백업한다. +- 남긴 것: `docs/chad/harness-sim-guide.html`의 옛 스킬 경로(다른 멤버 영역), 이 저장소 `.gitignore`의 `.harness-backup/` 항목. + + +## Reviews +*Codex 등 리뷰 실행 시 결과(요약·발견·조치)를 날짜와 함께 남긴다. 남기지 않은 리뷰는 "안 한 것"으로 간주.* +*기계 판독용 마커를 함께 남긴다: ``* + +### 2026-10-05T09:18:29.594Z — codex (harness-team review) + +- engine: codex · scope: diff · tip: 32ca082e1e68bc33edd3e07baa83ab56139893b1 · exit 0 · 768 B + +```text +전하, **P1/P2 결함은 발견하지 못했습니다.** + +- **P3** — [commands/harness-migrate.md:15](commands/harness-migrate.md#L15): 제거된 “backup dir 스크립트 → project root” 마이그레이션을 여전히 제공한다고 설명합니다. + +관리 절 백업(`.harness/backup/`), 일반 symlink 검사, doctor·init·migrate 흐름과 이동된 메인테이너 명령의 상대 경로에서 유의미한 회귀는 확인되지 않았습니다. + +검증: 읽기 전용 테스트 **22개 통과**, `docs:check`·`git diff --check` 통과. 쓰기가 필요한 전체 테스트와 실제 init·migrate 실행은 수행하지 않았습니다. 파일은 변경하지 않았습니다. + +**최종 판정: PASS — 문서 잔여 참조 1건 정리 권장.** +``` + + + +판별(2026-10-05): P3 1건 — **진짜 결함**. `commands/harness-migrate.md:15`가 제거된 백업 스크립트 변환을 여전히 설명했다 → 문구 삭제. + +## Learnings diff --git a/docs/hslee/remove-backup-cluster/remove-backup-cluster-context.md b/docs/hslee/remove-backup-cluster/remove-backup-cluster-context.md new file mode 100644 index 00000000..953f037e --- /dev/null +++ b/docs/hslee/remove-backup-cluster/remove-backup-cluster-context.md @@ -0,0 +1,22 @@ +# remove-backup-cluster — Context Card + + +## Now +- Goal: 백업 클론·AI gitignore 옵션 제거, 메인테이너 전용 명령 이동 +- Current atomic step: 외부 리뷰 → 커밋 · PR +- Stop / human-decision condition: 리뷰 BLOCKER, PR 머지는 사람 + +## Constraints and settled decisions +- + +## JIT retrieval map +- Identifiers / symbols: +- Narrow globs: +- Read next: +- Verification command: `npm run test && npm run docs:check` + +## Failure capsules (max 3 unresolved) +- (none) + +## Resume checklist +- diff --git a/docs/hslee/remove-backup-cluster/remove-backup-cluster-handoff.md b/docs/hslee/remove-backup-cluster/remove-backup-cluster-handoff.md new file mode 100644 index 00000000..de51bab6 --- /dev/null +++ b/docs/hslee/remove-backup-cluster/remove-backup-cluster-handoff.md @@ -0,0 +1,81 @@ +# remove-backup-cluster — Handoff + +(세션 종료 시 post-commit hook이 자동 갱신합니다) + +## 2026-10-05T09:16:09.921Z — 32ca082 feat!: 백업 클론과 AI 파일 gitignore 옵션을 제거하고 메인테이너 전용 명령을 배포에서 내린다 +.claude-plugin/plugin.json | 6 - + {commands => .claude/commands}/harness-release.md | 0 + {commands => .claude/commands}/harness-sim.md | 2 +- + CHANGELOG.md | 11 + + MAINTAINING.md | 2 + + README.md | 198 +-------------- + bin/harness-team.mjs | 10 - + commands/harness-clone.md | 15 -- + commands/harness-delete.md | 19 -- + commands/harness-init.md | 71 +----- + commands/harness-symlink.md | 15 -- + commands/harness-upgrade.md | 19 -- + docs/diagrams/harness-overview/architecture.mmd | 7 +- + docs/diagrams/harness-overview/backup-layout.mmd | 22 -- + .../harness-overview/backup-resolution.mmd | 21 -- + docs/diagrams/harness-overview/workflow.mmd | 4 - + docs/harness-overview.html | 274 +-------------------- + docs/harness-overview.template.html | 22 -- + docs/harness-workflow-simulation.html | 4 - + .../remove-backup-cluster-artifact.md | 19 ++ + .../remove-backup-cluster-context.md | 22 ++ + .../remove-backup-cluster-handoff.md | 3 + + .../remove-backup-cluster-meta.json | 11 + + .../remove-backup-cluster-plan.md | 19 ++ + .../remove-backup-cluster-spec.md | 54 ++++ + docs/index.html | 2 - + docs/prerequisites.md | 4 +- + .../maintainer-skills}/harness-codex-sim/SKILL.md | 0 + .../harness-codex-sim/agents/openai.yaml | 0 + .../maintainer-skills}/harness-release/SKILL.md | 2 +- + .../harness-release/agents/openai.yaml | 0 + .../maintainer-skills}/harness-sim/SKILL.md | 2 +- + .../harness-sim/agents/openai.yaml | 0 + skills/harness-clone/SKILL.md | 20 -- + skills/harness-clone/agents/openai.yaml | 4 - + skills/harness-delete/SKILL.md | 20 -- + skills/harness-delete/agents/openai.yaml | 4 - + skills/harness-symlink/SKILL.md | 20 -- + skills/harness-symlink/agents/openai.yaml | 4 - + skills/harness-upgrade/SKILL.md | 20 -- + skills/harness-upgrade/agents/openai.yaml | 4 - + src/backup-dir.mjs | 80 ------ + src/cli-args.mjs | 44 +--- + src/commands/backup.mjs | 91 ------- + src/commands/clone.mjs | 96 -------- + src/commands/delete.mjs | 89 ------- + src/commands/doctor.mjs | 66 +---- + src/commands/init.mjs | 65 +---- + src/commands/migrate.mjs | 104 +------- + src/commands/symlink.mjs | 110 --------- + src/commands/upgrade.mjs | 86 ------- + src/harness.mjs | 92 +------ + templates/clone.sh | 37 --- + templates/delete.sh | 27 -- + templates/symlink.sh | 58 ----- + tests/backup-dir.test.mjs | 176 ------------- + tests/cli-args.test.mjs | 21 +- + tests/delete.test.mjs | 61 ----- + tests/detect-stack.test.mjs | 4 +- + tests/doctor.test.mjs | 42 +--- + tests/documentation-inventory-pointers.test.mjs | 3 +- + tests/e2e/init-smoke.test.mjs | 2 - + tests/e2e/sandbox.mjs | 7 +- + tests/gitignore-entries.test.mjs | 25 +- + tests/sim/codex-agentloop.mjs | 2 +- + tests/symlink.test.mjs | 87 ------- + tests/task-paths-single-source.test.mjs | 2 +- + 67 files changed, 224 insertions(+), 2209 deletions(-) + +## 2026-10-05T09:19:02.248Z — e853b1f docs(migrate): 제거된 백업 스크립트 변환 설명을 지운다 (codex P3) +commands/harness-migrate.md | 2 +- + .../remove-backup-cluster-artifact.md | 19 ++++++ + .../remove-backup-cluster-handoff.md | 70 ++++++++++++++++++++++ + .../remove-backup-cluster-meta.json | 12 +++- + .../remove-backup-cluster-plan.md | 2 +- + 5 files changed, 102 insertions(+), 3 deletions(-) diff --git a/docs/hslee/remove-backup-cluster/remove-backup-cluster-meta.json b/docs/hslee/remove-backup-cluster/remove-backup-cluster-meta.json new file mode 100644 index 00000000..c3eafc9c --- /dev/null +++ b/docs/hslee/remove-backup-cluster/remove-backup-cluster-meta.json @@ -0,0 +1,21 @@ +{ + "user": "hslee", + "task": "remove-backup-cluster", + "created": "2026-10-05", + "firstActivatedAt": "2026-10-05T09:08:57.013Z", + "status": "open", + "closedAt": null, + "forcedAt": null, + "forcedIssues": null, + "reviews": [ + { + "kind": "codex", + "engine": "codex", + "scope": "diff", + "tip": "32ca082e1e68bc33edd3e07baa83ab56139893b1", + "at": "2026-10-05T09:18:29.594Z", + "exitCode": 0, + "outputBytes": 768 + } + ] +} diff --git a/docs/hslee/remove-backup-cluster/remove-backup-cluster-plan.md b/docs/hslee/remove-backup-cluster/remove-backup-cluster-plan.md new file mode 100644 index 00000000..b4e37812 --- /dev/null +++ b/docs/hslee/remove-backup-cluster/remove-backup-cluster-plan.md @@ -0,0 +1,19 @@ +# remove-backup-cluster — Plan + +## 목표 +D11 기준으로 사이클 밖 장치(백업 클론·AI gitignore 옵션)를 빼고, 메인테이너 전용 명령·스킬을 배포 표면에서 내린다. + +## 단계 +- [x] 소비자 사용 실측(세 곳 모두 backup.json·백업 폴더 있음, symlink 없음, job-scraper는 AI 파일 gitignore) → 메인테이너 재확인 +- [x] 제거·이동 구현(서브에이전트 위임) +- [x] 메인 세션 검증: 전체 테스트, docs:check, grep, 핵심 diff 3곳, 임시 저장소 init·doctor 스모크 +- [x] 외부 리뷰(R3) 기록 — codex PASS, P3 1건 반영 +- [x] 커밋 · PR (#122) + +## Ontology 변경 로그 +*개념이 새로 정의되거나 의미가 바뀌면 한 줄로 기록. spec.md의 Ontology 섹션을 갱신할 트리거가 된다.* + +- 2026-10-05 "백업 클론"(제거)과 "관리 절 백업"(유지) 구분. + +## 참고 +- 머지 후 종결(`done`·`summary --write`)은 기본 브랜치 몫이다. diff --git a/docs/hslee/remove-backup-cluster/remove-backup-cluster-spec.md b/docs/hslee/remove-backup-cluster/remove-backup-cluster-spec.md new file mode 100644 index 00000000..809ed1a5 --- /dev/null +++ b/docs/hslee/remove-backup-cluster/remove-backup-cluster-spec.md @@ -0,0 +1,54 @@ +# remove-backup-cluster — Spec + +## 목적 / 요구사항 +*문제(오늘 무엇이 안 되는가) → 영향받는 사용자·시스템 → 기대 결과 → 제약 순으로 쓴다. +답 없는 질문은 요구가 아니다 — `## 참고` 절에 `- (open) …`으로 남긴다.* + +- **문제**: D11(`docs/decisions.md`) 기준으로 사이클 어느 단계에도 봉사하지 않는 장치가 남아 있다 — 저장소 밖 백업 클론 + (`../harness-backup//`, `backup.json`, `clone.sh`·`symlink.sh`·`delete.sh`, backup·clone·symlink·delete·upgrade 명령)과 + 그 짝인 init의 "AI 설정 파일 gitignore" 옵션. 후자는 팀이 같은 `AGENTS.md`를 git으로 공유한다는 정체성과 정면으로 어긋난다. + 또 이 저장소에서만 동작하는 sim·codex-sim·release가 소비자 메뉴에 배포된다. +- **영향**: 소비자 init 흐름(백업 위치 질문·gitignore 질문), doctor(`backup.json` 필수 검사), 소비자 슬래시 메뉴·Codex 스킬. +- **기대 결과**: 위 장치 제거. 새 설치가 doctor에서 실패·경고하지 않는다. 기존 백업 폴더·`backup.json`은 건드리지 않는다. +- **제약**: migrate의 관리 절 백업(`.harness/backup/managed-sections-*`)은 다른 기능이라 유지. 이력 문서는 고치지 않는다. + + +## 설계 / 접근 +2026-10-05 메인테이너 결정(cycle §4-6, 이 대화). 구현은 서브에이전트(sonnet)에 위임하고 메인 세션이 검증했다. +- 제거: `src/commands/{backup,clone,symlink,delete,upgrade}.mjs`, `src/backup-dir.mjs`, `templates/{clone,delete,symlink}.sh`, + 명령·스킬 4종, init의 백업·gitignore 질문과 플래그(`--no-backup`·`--backup-dir`·`--backup-parent`·`--gitignore-ai`), + migrate의 `migrateBackupScripts`·`refreshProjectScripts`, doctor의 `backup.json` 필수·백업 스크립트·백업 클론 검사, `cloudSyncPathWarning`. +- 이동: `commands/harness-{sim,release}.md` → `.claude/commands/`, `skills/harness-{sim,codex-sim,release}/` → `scripts/maintainer-skills/`. +- 문서: README·MAINTAINING·prerequisites·index·overview(생성물 재생성)·workflow-simulation(현행본). + + +## Ontology +*이 task가 다루는 핵심 개념의 정의. "X가 정확히 뭔가?"에 답한다.* + +- **백업 클론**: 저장소 밖 형제 폴더에 AI 설정을 복사·링크하던 장치. AI 설정을 git에 넣지 않는 프로젝트를 위한 것이었다. +- **관리 절 백업**: migrate가 관리 절 원본을 `.harness/backup/`에 남기는 것 — 이름만 비슷한 별개 기능, 유지. + +## Ambiguity 자가진단 +*각 항목이 명확하면 체크. 3개 이상 미체크면 구현 진입 금지 — 인터뷰/브레인스토밍으로 복귀해 +모호성을 제거한다. 게이트를 통과하면 그 근거를 위 Ontology 섹션에 한 줄로 남긴다.* + +- [x] **Goal 명확도** (40%) — 목표가 한 문장으로 구체화되었는가? +- [x] **Constraint 명확도** (30%) — 기술/시간/범위 제약이 명시되었는가? +- [x] **Success 기준** (30%) — 완료를 어떻게 측정하는가? +- [x] **Context 명확도** (brownfield 한정) — 영향 받는 기존 코드/파일을 식별했는가? +- [x] **Ambiguity ≤ 0.2** — 위 항목 가중합 ≥ 0.8 + + +## Done evidence +```json +{ "version": 1, "review": "required", "tests": "required" } +``` +성공 기준: `npm run test` fail 0, `npm run docs:check` 통과, 제거 식별자 grep이 이력·거부 테스트 외 0건, 임시 저장소 init이 백업 질문 없이 끝나고 doctor에 백업 관련 경고·실패 없음. + +## 참고 +*코드 기반 참조가 산문 설계보다 정밀하다 — 테스트 스위트·Boundary contract(JSON Schema)· +다이어그램·기존 코드 경로를 우선 링크하고, 산문은 코드로 표현 못 하는 의도만 담는다.* + +- diff --git a/docs/index.html b/docs/index.html index 28d71b7e..9f731d8d 100644 --- a/docs/index.html +++ b/docs/index.html @@ -300,8 +300,6 @@

Diagram sources

  • rules.mmd
  • plugin.mmd
  • hooks.mmd
  • -
  • backup-layout.mmd
  • -
  • backup-resolution.mmd
  • diff --git a/docs/prerequisites.md b/docs/prerequisites.md index dc2f8ac7..d0d3e73d 100644 --- a/docs/prerequisites.md +++ b/docs/prerequisites.md @@ -135,8 +135,8 @@ Codex 플러그인은 Claude의 `commands[]`를 가져오지 않으므로 **따 ### 벤더링되지 않은 스킬 — 미리 설치해야 켜지는 것 (선택) -`/harness-*` 슬래시 커맨드 25종과 짝이 되는 스킬(그리고 Codex 전용 `harness-team`· -`harness-codex-sim` 스킬)은 **전부 플러그인에 번들**되어 있어 따로 설치할 것이 없습니다. +`/harness-*` 슬래시 커맨드 19종과 짝이 되는 스킬(그리고 Codex 전용 `harness-team` +스킬)은 **전부 플러그인에 번들**되어 있어 따로 설치할 것이 없습니다. 하네스가 참조하지만 **번들하지 않는**(= 쓰려면 미리 설치해야 하는) 스킬은 현재 아래 **하나뿐**입니다. diff --git a/skills/harness-codex-sim/SKILL.md b/scripts/maintainer-skills/harness-codex-sim/SKILL.md similarity index 100% rename from skills/harness-codex-sim/SKILL.md rename to scripts/maintainer-skills/harness-codex-sim/SKILL.md diff --git a/skills/harness-codex-sim/agents/openai.yaml b/scripts/maintainer-skills/harness-codex-sim/agents/openai.yaml similarity index 100% rename from skills/harness-codex-sim/agents/openai.yaml rename to scripts/maintainer-skills/harness-codex-sim/agents/openai.yaml diff --git a/skills/harness-release/SKILL.md b/scripts/maintainer-skills/harness-release/SKILL.md similarity index 93% rename from skills/harness-release/SKILL.md rename to scripts/maintainer-skills/harness-release/SKILL.md index 69cab04d..1ec68b23 100644 --- a/skills/harness-release/SKILL.md +++ b/scripts/maintainer-skills/harness-release/SKILL.md @@ -9,7 +9,7 @@ Use this skill as the Codex equivalent of Claude Code `/harness-release`. ## Source of Truth -- Read `../../commands/harness-release.md` before acting. +- Read `../../../.claude/commands/harness-release.md` before acting. - Follow that command contract, plus `AGENTS.md` and active task docs when present. - Prefer the shared CLI instead of reimplementing behavior: - In this plugin source repo, run: `node bin/harness-team.mjs release ...` diff --git a/skills/harness-release/agents/openai.yaml b/scripts/maintainer-skills/harness-release/agents/openai.yaml similarity index 100% rename from skills/harness-release/agents/openai.yaml rename to scripts/maintainer-skills/harness-release/agents/openai.yaml diff --git a/skills/harness-sim/SKILL.md b/scripts/maintainer-skills/harness-sim/SKILL.md similarity index 99% rename from skills/harness-sim/SKILL.md rename to scripts/maintainer-skills/harness-sim/SKILL.md index 02bbcc4b..e99cb74d 100644 --- a/skills/harness-sim/SKILL.md +++ b/scripts/maintainer-skills/harness-sim/SKILL.md @@ -9,7 +9,7 @@ allowed-tools: Read, Write, Edit, Bash, Glob ## Codex command surface This skill also serves as the Codex `$harness-sim` entry corresponding to Claude -Code `/harness-sim`. The Claude wrapper lives at `../../commands/harness-sim.md`; +Code `/harness-sim`. The Claude wrapper lives at `../../../.claude/commands/harness-sim.md`; keep this SKILL.md as the SSOT for the simulation procedure. **측정 대상(2번):** `harness-init`/`task`로 소비자 프로젝트에 *설치된* 하네스가 diff --git a/skills/harness-sim/agents/openai.yaml b/scripts/maintainer-skills/harness-sim/agents/openai.yaml similarity index 100% rename from skills/harness-sim/agents/openai.yaml rename to scripts/maintainer-skills/harness-sim/agents/openai.yaml diff --git a/skills/harness-clone/SKILL.md b/skills/harness-clone/SKILL.md deleted file mode 100644 index 616be084..00000000 --- a/skills/harness-clone/SKILL.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: harness-clone -description: Codex wrapper for syncing a project into its harness backup directory. Use when the user asks for /harness-clone, harness clone, project backup sync, merge sync, or newer-wins backup copy. ---- - -# Harness Clone - -Use this skill as the Codex equivalent of Claude Code `/harness-clone`. - -## Source of Truth - -- Read `../../commands/harness-clone.md` before acting. -- Follow that command contract, plus `AGENTS.md` and active task docs when present. -- Prefer the shared CLI instead of reimplementing behavior: - - In this plugin source repo, run: `node bin/harness-team.mjs clone ...` - - In a consumer project with `harness-team` on PATH, run: `harness-team clone ...` -- Translate Claude-only references for Codex: - - `${CLAUDE_PLUGIN_ROOT}` means this installed plugin root or this repository root. - - `AskUserQuestion` means ask a concise user question only when required; otherwise proceed with safe defaults. -- Do not create commits unless the user explicitly asks. diff --git a/skills/harness-clone/agents/openai.yaml b/skills/harness-clone/agents/openai.yaml deleted file mode 100644 index 81fbb642..00000000 --- a/skills/harness-clone/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Harness Clone" - short_description: "Sync project files to the backup dir." - default_prompt: "Use $harness-clone to sync the project backup." diff --git a/skills/harness-delete/SKILL.md b/skills/harness-delete/SKILL.md deleted file mode 100644 index 5cf32bb9..00000000 --- a/skills/harness-delete/SKILL.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: harness-delete -description: Codex wrapper for removing harness symlinks from a project. Use when the user asks for /harness-delete, harness delete, remove harness symlinks, or uninstall project harness links. ---- - -# Harness Delete - -Use this skill as the Codex equivalent of Claude Code `/harness-delete`. - -## Source of Truth - -- Read `../../commands/harness-delete.md` before acting. -- Follow that command contract, plus `AGENTS.md` and active task docs when present. -- Prefer the shared CLI instead of reimplementing behavior: - - In this plugin source repo, run: `node bin/harness-team.mjs delete ...` - - In a consumer project with `harness-team` on PATH, run: `harness-team delete ...` -- Translate Claude-only references for Codex: - - `${CLAUDE_PLUGIN_ROOT}` means this installed plugin root or this repository root. - - `AskUserQuestion` means ask a concise user question only when required; otherwise proceed with safe defaults. -- Do not create commits unless the user explicitly asks. diff --git a/skills/harness-delete/agents/openai.yaml b/skills/harness-delete/agents/openai.yaml deleted file mode 100644 index 56aeec3c..00000000 --- a/skills/harness-delete/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Harness Delete" - short_description: "Remove harness symlinks from a project." - default_prompt: "Use $harness-delete to remove harness symlinks." diff --git a/skills/harness-symlink/SKILL.md b/skills/harness-symlink/SKILL.md deleted file mode 100644 index fbea5a8e..00000000 --- a/skills/harness-symlink/SKILL.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: harness-symlink -description: Codex wrapper for creating or replacing harness backup symlinks. Use when the user asks for /harness-symlink, harness symlink, backup symlink setup, or relink harness backup files. ---- - -# Harness Symlink - -Use this skill as the Codex equivalent of Claude Code `/harness-symlink`. - -## Source of Truth - -- Read `../../commands/harness-symlink.md` before acting. -- Follow that command contract, plus `AGENTS.md` and active task docs when present. -- Prefer the shared CLI instead of reimplementing behavior: - - In this plugin source repo, run: `node bin/harness-team.mjs symlink ...` - - In a consumer project with `harness-team` on PATH, run: `harness-team symlink ...` -- Translate Claude-only references for Codex: - - `${CLAUDE_PLUGIN_ROOT}` means this installed plugin root or this repository root. - - `AskUserQuestion` means ask a concise user question only when required; otherwise proceed with safe defaults. -- Do not create commits unless the user explicitly asks. diff --git a/skills/harness-symlink/agents/openai.yaml b/skills/harness-symlink/agents/openai.yaml deleted file mode 100644 index 1f8d04e8..00000000 --- a/skills/harness-symlink/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Harness Symlink" - short_description: "Create or replace harness symlinks." - default_prompt: "Use $harness-symlink to recreate harness symlinks." diff --git a/skills/harness-upgrade/SKILL.md b/skills/harness-upgrade/SKILL.md deleted file mode 100644 index 1c14ae82..00000000 --- a/skills/harness-upgrade/SKILL.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: harness-upgrade -description: Codex wrapper for upgrading older real-file harness setups to symlink-based layout. Use when the user asks for /harness-upgrade, harness upgrade, symlink migration, or old harness upgrade. ---- - -# Harness Upgrade - -Use this skill as the Codex equivalent of Claude Code `/harness-upgrade`. - -## Source of Truth - -- Read `../../commands/harness-upgrade.md` before acting. -- Follow that command contract, plus `AGENTS.md` and active task docs when present. -- Prefer the shared CLI instead of reimplementing behavior: - - In this plugin source repo, run: `node bin/harness-team.mjs upgrade ...` - - In a consumer project with `harness-team` on PATH, run: `harness-team upgrade ...` -- Translate Claude-only references for Codex: - - `${CLAUDE_PLUGIN_ROOT}` means this installed plugin root or this repository root. - - `AskUserQuestion` means ask a concise user question only when required; otherwise proceed with safe defaults. -- Do not create commits unless the user explicitly asks. diff --git a/skills/harness-upgrade/agents/openai.yaml b/skills/harness-upgrade/agents/openai.yaml deleted file mode 100644 index 5f05eebe..00000000 --- a/skills/harness-upgrade/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Harness Upgrade" - short_description: "Upgrade older harness layouts to symlinks." - default_prompt: "Use $harness-upgrade to upgrade this harness layout." diff --git a/src/backup-dir.mjs b/src/backup-dir.mjs deleted file mode 100644 index 00059e3f..00000000 --- a/src/backup-dir.mjs +++ /dev/null @@ -1,80 +0,0 @@ -import { join, resolve, dirname, basename } from 'node:path'; -import { homedir } from 'node:os'; -import { lstat, readlink } from 'node:fs/promises'; -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; -import { readTextSafe, exists } from './fsx.mjs'; - -const pexec = promisify(execFile); - -const PROBE_ITEMS = ['CLAUDE.md', '.claude']; -const DEFAULT_BACKUP_PARENT = 'harness-backup'; - -// targetDir 에 대응하는 **메인 체크아웃 쪽** 경로. 백업 경로의 `..`·자동 탐지 이름은 이 기준으로 푼다. -// 링크된 워크트리(`/.claude/worktrees/` 등)에서 targetDir 기준으로 풀면 `..`가 워크트리 부모를 가리킨다. -// `--show-toplevel`은 워크트리에서 그 워크트리를 돌려주므로 쓰지 않는다 — common-dir(= 메인 `.git`)의 부모에 -// `--show-prefix`(하위 디렉터리 설치본의 상대 위치)를 붙인다. 메인 체크아웃·비-git·bare(`.git`에 둔 bare 포함)· -// submodule 처럼 메인 체크아웃을 특정할 수 없으면 targetDir 그대로(종전 동작, 문자열까지 같다). -// 저장소 선택 환경변수(GIT_DIR 등 — git 훅 안에서 설정된다)는 떼어 낸다: 남기면 `-C targetDir` 탐색을 무시하고 -// 그 저장소를 답해 비-git 대상까지 메인 체크아웃으로 오인한다. -export async function backupAnchor(targetDir) { - const { GIT_DIR, GIT_WORK_TREE, GIT_COMMON_DIR, ...env } = process.env; - const git = (cwd, ...args) => pexec('git', ['-C', cwd, ...args], { env, maxBuffer: 1024 * 1024 }); - try { - const { stdout } = await git(targetDir, 'rev-parse', '--git-dir', '--git-common-dir', '--show-prefix'); - const [gitDir, commonDir, prefix = ''] = stdout.split('\n'); - const gitAbs = resolve(targetDir, gitDir); - const commonAbs = resolve(targetDir, commonDir); - if (gitAbs === commonAbs || basename(commonAbs) !== '.git') return targetDir; - // 부모가 정말 이 common-dir 의 작업 트리인지 직접 묻는다 — `.git`이라는 이름의 bare 저장소, - // `core.worktree`로 작업 트리를 딴 곳에 둔 저장소(worktreeConfig 포함)는 부모가 체크아웃이 아니다. - const mainRoot = dirname(commonAbs); - const isMain = await git(mainRoot, 'rev-parse', '--is-inside-work-tree', '--git-common-dir').then(({ stdout }) => { - const [inside, common] = stdout.split('\n'); - return inside === 'true' && resolve(mainRoot, common) === commonAbs; - }, () => false); - if (!isMain) return targetDir; - return join(mainRoot, prefix.replace(/\/$/, '')); - } catch { return targetDir; } -} - -// `.harness/backup.json` 내용 → 백업 경로. `{dir}`은 그대로, `{parent,name}`은 anchor 의 형제 폴더. 해석 불가면 null. -export async function backupDirFromConfig(targetDir, data) { - if (data?.dir) return data.dir; - const { parent, name } = data ?? {}; - if (!parent || !name) return null; - return resolve(join(await backupAnchor(targetDir), '..', parent, name)); -} - -export async function resolveBackupDir(targetDir, { backupDir } = {}) { - if (backupDir) return resolve(backupDir.replace(/^~/, homedir())); - - // 1. Try .harness/backup.json - const cfg = await readTextSafe(join(targetDir, '.harness/backup.json')); - if (cfg) { - try { - const fromCfg = await backupDirFromConfig(targetDir, JSON.parse(cfg)); - if (fromCfg) return fromCfg; - } catch {} - } - - // 2. Fallback: probe existing symlinks to reverse-engineer backup dir - for (const item of PROBE_ITEMS) { - const p = join(targetDir, item); - try { - const st = await lstat(p); - if (st.isSymbolicLink()) { - const raw = await readlink(p); - const resolved = resolve(targetDir, raw); - return dirname(resolved); - } - } catch {} - } - - // 3. Auto-detect ../harness-backup/ - const anchor = await backupAnchor(targetDir); - const autoPath = join(anchor, '..', DEFAULT_BACKUP_PARENT, basename(anchor)); - if (await exists(autoPath)) return autoPath; - - return null; -} diff --git a/src/cli-args.mjs b/src/cli-args.mjs index 31134d1f..84f65332 100644 --- a/src/cli-args.mjs +++ b/src/cli-args.mjs @@ -15,20 +15,14 @@ import { KNOWN_STACK_IDS } from './detect-stack.mjs'; -export const VALUE_FLAGS = new Set(['stack', 'member', 'target', 'backup-dir', 'backup-parent', 'days', 'name', 'paths', 'framing', 'rubric', 'prompt-file', 'scope', 'base', 'area']); +export const VALUE_FLAGS = new Set(['stack', 'member', 'target', 'days', 'name', 'paths', 'framing', 'rubric', 'prompt-file', 'scope', 'base', 'area']); // Accepted on every command: they change where the harness looks or how it // reports, not what it does. Keeping them global means a hook can pass --target // to any subcommand without the registry having to enumerate it per command. export const GLOBAL_FLAGS = ['target', 'member', 'yes', 'json']; -// Only the commands that pass an override into `resolveBackupDir` list -// `backup-dir`; `backup-parent` is read by init alone. `backup` and -// `migrate` call `loadBackupDir` and ignore both, so they must not advertise -// them — a flag that is accepted and then ignored is the same silent-default -// failure this module exists to remove. -const BACKUP_DIR_FLAG = ['backup-dir']; -const INIT_FLAGS = ['backup-dir', 'backup-parent', 'stack', 'no-backup', 'gitignore-ai', 'no-gitignore-ai']; +const INIT_FLAGS = ['stack']; // Single source for the command table: `--help` renders from `summary`, flag // validation reads `flags`. A flag that is not listed here cannot be passed, @@ -38,22 +32,12 @@ export const COMMANDS = [ // skip existing files, so applying it to a project that already has the harness // refreshes managed sections without touching user text. { name: 'init', args: '[dir]', summary: 'Scaffold the harness, or refresh an existing install (non-destructive)', flags: INIT_FLAGS }, - { name: 'backup', args: '[dir]', summary: 'Move harness items to backup dir and replace with symlinks', - flags: [] }, - { name: 'clone', args: '[dir]', summary: 'Sync project items to backup dir (merge, newer-wins)', - flags: BACKUP_DIR_FLAG }, - { name: 'symlink', args: '[dir]', summary: 'Create backup→project symlinks', - flags: BACKUP_DIR_FLAG }, - { name: 'delete', args: '[dir]', summary: 'Remove harness symlinks from project', - flags: [...BACKUP_DIR_FLAG, 'include-real'] }, // `--adopt-reviews` is opt-in on purpose: it flips legacy tasks to CLI-owned review // evidence, which drops their hand-written verify markers from the guard's count. // Every other migrate step is a structure move that is safe unattended; this one is not, // so it never runs from a bare `migrate` (or from `--yes` alone). - { name: 'migrate', args: '[dir] [--adopt-reviews]', summary: 'Migrate to latest: backup scripts → root, task structure (→0.6, →0.7 artifact.md split); --adopt-reviews moves legacy tasks to CLI-owned review evidence', + { name: 'migrate', args: '[dir] [--adopt-reviews]', summary: 'Migrate to latest: task structure (→0.6, →0.7 artifact.md split); --adopt-reviews moves legacy tasks to CLI-owned review evidence', flags: ['adopt-reviews'] }, - { name: 'upgrade', args: '[dir]', summary: 'Migrate real files → symlinks in one step (v0.3.x → v0.4+)', - flags: BACKUP_DIR_FLAG }, { name: 'sync', args: '[dir]', summary: 'Mirror .claude/rules → .cursor/rules and reinstall the post-commit hook', flags: [] }, { name: 'doctor', args: '[dir]', summary: 'Diagnose harness integrity', flags: [] }, // Read-only. Owns the dependency lookup that the three test commands' 0단계 used to @@ -113,10 +97,7 @@ const OPTIONS_HELP = `Options: --stack Force stack (${KNOWN_STACK_IDS.join('|')}) --yes Non-interactive --member Override member (default: git config user.name, else $USER) - --no-backup Skip backup dir setup entirely (init only) --target Target directory (default: cwd) - --gitignore-ai Add AI tool entries to .gitignore without prompting - --no-gitignore-ai Skip AI gitignore entries without prompting --json Structured JSON envelope output for drive commands (task/retro/release/doctor/summary/observe/rules/stack/scope/config/diagram)`; // `doctor` proves the hook CLI is reachable by matching `session-context` and @@ -189,23 +170,6 @@ export function parseArgs(argv) { return out; } -// Negations the parser rewrites before dispatch: the command reads one key, so -// it never sees the name the caller typed. Declared here so a test can tell -// "this flag is deliberately renamed" apart from "this flag is ignored". -export const FLAG_ALIASES = new Map([['no-gitignore-ai', 'gitignore-ai']]); - -// Normalization runs after validation so an error message names the flag the -// caller actually typed. -function normalize(flags) { - const out = { ...flags }; - for (const [alias, target] of FLAG_ALIASES) { - if (out[alias] === undefined) continue; - out[target] = false; - delete out[alias]; - } - return out; -} - export function unknownFlags(command, flags) { const accepted = new Set([...command.flags, ...GLOBAL_FLAGS]); return Object.keys(flags).filter(flag => !accepted.has(flag)); @@ -255,5 +219,5 @@ export function resolveInvocation(argv) { }; } - return { kind: 'run', cmd: command.name, positional: parsed.positional, flags: normalize(parsed.flags) }; + return { kind: 'run', cmd: command.name, positional: parsed.positional, flags: parsed.flags }; } diff --git a/src/commands/backup.mjs b/src/commands/backup.mjs deleted file mode 100644 index febba0ca..00000000 --- a/src/commands/backup.mjs +++ /dev/null @@ -1,91 +0,0 @@ -import { join } from 'node:path'; -import { mkdir, lstat, cp, rm, symlink, unlink } from 'node:fs/promises'; -import { loadBackupDir } from '../harness.mjs'; -import { confirm } from '../prompt.mjs'; - -// Items moved from project into backupDir, then replaced with absolute symlinks. -// AGENTS.md is now a real canonical file (not CLAUDE.md aliases), so they -// are moved like any other real file — never recreated as aliases. -const MOVE_ITEMS = ['CLAUDE.md', 'AGENTS.md', '.claude', '.cursor', '.codex', 'docs', '.harness']; - -// Alias machinery retired: the only former alias (.cursorrules) is dropped, AGENTS.md/ -// AGENTS.md became a real file. Kept as an empty list to leave the wiring loop inert. -const ALIAS_ITEMS = []; - -export async function runBackup(ctx) { - const backupDir = await loadBackupDir(ctx.targetDir); - if (!backupDir) { - console.error('No backup dir configured. Run harness-team init first.'); - process.exit(1); - } - - console.log(`harness-team backup → ${ctx.targetDir}`); - console.log(` backup dir: ${backupDir}`); - - const toMove = []; - for (const item of MOVE_ITEMS) { - const src = join(ctx.targetDir, item); - const st = await lstat(src).catch(() => null); - if (!st) continue; - if (st.isSymbolicLink()) { console.log(` skip: ${item} (already a symlink)`); continue; } - toMove.push(item); - } - - const toAlias = []; - for (const item of ALIAS_ITEMS) { - const src = join(ctx.targetDir, item); - const dst = join(backupDir, item); - const srcSt = await lstat(src).catch(() => null); - const dstSt = await lstat(dst).catch(() => null); - // needs work if project item isn't already an abs symlink to backupDir - const alreadyDone = srcSt?.isSymbolicLink() && dstSt; - if (!alreadyDone) toAlias.push(item); - else console.log(` skip: ${item} (already a symlink)`); - } - - if (toMove.length === 0 && toAlias.length === 0) { - console.log('Nothing to back up — all items already symlinked.'); - return; - } - - if (toMove.length > 0) { - console.log('\nItems to move to backup dir:'); - for (const item of toMove) console.log(` ${item}`); - } - if (toAlias.length > 0) { - console.log('\nAlias items to wire into backup dir:'); - for (const item of toAlias) console.log(` ${item}`); - } - - const ok = ctx.flags.yes || await confirm('\nProceed?', { defaultYes: true }); - if (!ok) { console.log('Aborted.'); return; } - - await mkdir(backupDir, { recursive: true }); - - for (const item of toMove) { - const src = join(ctx.targetDir, item); - const dst = join(backupDir, item); - await cp(src, dst, { recursive: true }); - await rm(src, { recursive: true, force: true }); - await symlink(dst, src); - console.log(` ✓ move ${item} → ${dst}`); - } - - for (const item of toAlias) { - const src = join(ctx.targetDir, item); - const dst = join(backupDir, item); - // ensure backupDir alias exists as a relative symlink → CLAUDE.md - if (!await lstat(dst).catch(() => null)) { - await symlink('CLAUDE.md', dst); - console.log(` ✓ alias ${item} created in backup dir`); - } - // replace project version (relative symlink or file) with absolute symlink → backupDir/$item - const srcSt = await lstat(src).catch(() => null); - if (srcSt?.isSymbolicLink()) await unlink(src); - else if (srcSt) await rm(src, { recursive: true, force: true }); - await symlink(dst, src); - console.log(` ✓ link ${item} → ${dst}`); - } - - console.log('\n✓ Backup complete. Run harness-doctor to verify.'); -} diff --git a/src/commands/clone.mjs b/src/commands/clone.mjs deleted file mode 100644 index 236b8ca4..00000000 --- a/src/commands/clone.mjs +++ /dev/null @@ -1,96 +0,0 @@ -import { join, resolve } from 'node:path'; -import { lstat, readlink, mkdir, copyFile, readdir, symlink as makeSymlink } from 'node:fs/promises'; -import { resolveBackupDir } from '../backup-dir.mjs'; -import { confirm } from '../prompt.mjs'; - -const ITEMS = ['CLAUDE.md', '.claude', '.cursor', '.codex', 'docs', '.harness']; - -async function mergeDirNewer(src, dst) { - await mkdir(dst, { recursive: true }); - const entries = await readdir(src, { withFileTypes: true }); - for (const e of entries) { - if (e.name === '.DS_Store') continue; - const s = join(src, e.name); - const d = join(dst, e.name); - if (e.isDirectory()) { - await mergeDirNewer(s, d); - } else { - const srcSt = await lstat(s); - const dstSt = await lstat(d).catch(() => null); - if (!dstSt || srcSt.mtimeMs > dstSt.mtimeMs) await copyFile(s, d); - } - } -} - -export async function runClone(ctx) { - const backupDir = await resolveBackupDir(ctx.targetDir, { backupDir: ctx.flags['backup-dir'] }); - if (!backupDir) { - console.error('No backup dir found. Run `harness-team init` or `harness-team backup` first.'); - process.exit(1); - } - - console.log(`harness-team clone → ${ctx.targetDir}`); - console.log(` backup dir: ${backupDir}`); - - const ops = []; - for (const item of ITEMS) { - const src = join(ctx.targetDir, item); - const dst = join(backupDir, item); - const st = await lstat(src).catch(() => null); - if (!st) { ops.push({ item, kind: 'skip', reason: 'not found' }); continue; } - - if (st.isSymbolicLink()) { - const raw = await readlink(src); - const resolved = resolve(ctx.targetDir, raw); - if (resolved === backupDir || resolved.startsWith(backupDir + '/')) { - ops.push({ item, kind: 'skip', reason: 'harness symlink' }); - } else { - ops.push({ item, kind: 'symlink', src, dst, target: resolved }); - } - } else if (st.isDirectory()) { - ops.push({ item, kind: 'dir', src, dst }); - } else { - ops.push({ item, kind: 'file', src, dst }); - } - } - - const toProcess = ops.filter(o => o.kind !== 'skip'); - if (toProcess.length === 0) { - console.log('Nothing to clone — all items are harness symlinks.'); - return; - } - - console.log('\nItems to sync to backup dir:'); - for (const op of toProcess) console.log(` ${op.kind}: ${op.item}`); - - const ok = ctx.flags.yes || await confirm('\nProceed?', { defaultYes: true }); - if (!ok) { console.log('Aborted.'); return; } - - await mkdir(backupDir, { recursive: true }); - - for (const op of toProcess) { - if (op.kind === 'symlink') { - const dstSt = await lstat(op.dst).catch(() => null); - if (!dstSt) { - await makeSymlink(op.target, op.dst); - console.log(` ✓ synced symlink: ${op.item} → ${op.target}`); - } else { - console.log(` skip: ${op.item} (symlink already exists in backup)`); - } - } else if (op.kind === 'dir') { - await mergeDirNewer(op.src, op.dst); - console.log(` ✓ merged dir: ${op.item}`); - } else { - const srcSt = await lstat(op.src); - const dstSt = await lstat(op.dst).catch(() => null); - if (!dstSt || srcSt.mtimeMs > dstSt.mtimeMs) { - await copyFile(op.src, op.dst); - console.log(` ✓ copied (newer): ${op.item}`); - } else { - console.log(` skip: ${op.item} (backup newer or equal)`); - } - } - } - - console.log('\n✓ Clone complete.'); -} diff --git a/src/commands/delete.mjs b/src/commands/delete.mjs deleted file mode 100644 index e1ee8c0c..00000000 --- a/src/commands/delete.mjs +++ /dev/null @@ -1,89 +0,0 @@ -import { join, resolve } from 'node:path'; -import { lstat, readlink, unlink, rm } from 'node:fs/promises'; -import { resolveBackupDir } from '../backup-dir.mjs'; -import { readTextSafe } from '../fsx.mjs'; -import { confirm } from '../prompt.mjs'; - -// AGENTS.md is a real file now → treated as MOVE items so --include-real -// teardown removes them. The alias list is retired (.cursorrules dropped). -const MOVE_ITEMS = ['CLAUDE.md', 'AGENTS.md', '.claude', '.cursor', '.codex', 'docs', '.harness', 'clone.sh', 'delete.sh']; -const ALIAS_ITEMS = []; - -export async function runDelete(ctx) { - const includeReal = !!ctx.flags['include-real']; - const backupDir = await resolveBackupDir(ctx.targetDir, { backupDir: ctx.flags['backup-dir'] }); - if (!backupDir) { - console.error('No backup dir found. Run `harness-team init` or `harness-team backup` first.'); - process.exit(1); - } - - console.log(`harness-team delete → ${ctx.targetDir}`); - console.log(` backup dir: ${backupDir}`); - - const toRemove = []; - let savedBackupConfig = null; - - for (const item of MOVE_ITEMS) { - const p = join(ctx.targetDir, item); - const st = await lstat(p).catch(() => null); - if (!st) continue; - if (st.isSymbolicLink()) { - const raw = await readlink(p); - const resolved = resolve(ctx.targetDir, raw); - if (resolved === backupDir || resolved.startsWith(backupDir + '/')) { - toRemove.push({ item, p, kind: 'symlink' }); - } else { - console.log(` skip: ${item} (local symlink → ${raw})`); - } - } else if (includeReal) { - if (item === '.harness') { - const cfg = await readTextSafe(join(p, 'backup.json')); - if (cfg) { - try { savedBackupConfig = JSON.parse(cfg); } catch {} - } - } - toRemove.push({ item, p, kind: 'real', isDir: st.isDirectory() }); - } else { - console.log(` skip: ${item} (not a symlink)`); - } - } - - for (const item of ALIAS_ITEMS) { - const p = join(ctx.targetDir, item); - const st = await lstat(p).catch(() => null); - if (!st) continue; - if (st.isSymbolicLink()) { - toRemove.push({ item, p, kind: 'symlink' }); - } else { - console.log(` skip: ${item} (not a symlink, leaving untouched)`); - } - } - - if (toRemove.length === 0) { console.log('Nothing to remove.'); return { savedBackupConfig }; } - - console.log('\nItems to remove:'); - for (const op of toRemove) { - const label = op.kind === 'real' ? ' (real file/dir — PERMANENT)' : ''; - console.log(` ${op.item}${label}`); - } - - const hasReal = toRemove.some(o => o.kind === 'real'); - const question = hasReal - ? '\nThis will PERMANENTLY delete real files/dirs. Proceed?' - : '\nProceed?'; - - const ok = ctx.flags.yes || await confirm(question, { defaultYes: !hasReal }); - if (!ok) { console.log('Aborted.'); return { savedBackupConfig }; } - - for (const op of toRemove) { - if (op.kind === 'real') { - await rm(op.p, { recursive: true, force: true }); - } else { - await unlink(op.p); - } - console.log(` ✓ removed: ${op.item}`); - } - - console.log('\n✓ Delete complete.'); - return { savedBackupConfig }; -} diff --git a/src/commands/doctor.mjs b/src/commands/doctor.mjs index bdaf18bb..32a67fb1 100644 --- a/src/commands/doctor.mjs +++ b/src/commands/doctor.mjs @@ -4,7 +4,7 @@ import { join, isAbsolute, resolve, basename } from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { exists, readTextSafe } from '../fsx.mjs'; -import { loadBackupDir, settingsHasBoundaryCheckpoint, codexHooksHaveSessionContext, isHarnessCodexSessionCommand, AGENT_FILE_TEMPLATES } from '../harness.mjs'; +import { settingsHasBoundaryCheckpoint, codexHooksHaveSessionContext, isHarnessCodexSessionCommand, AGENT_FILE_TEMPLATES } from '../harness.mjs'; import { render } from '../render.mjs'; import { resolveStack } from '../detect-stack.mjs'; import { loadRenderState, readHarnessVersion } from '../render-state.mjs'; @@ -786,22 +786,17 @@ const CHECKS = [ { path: '.cursor/rules', required: false, dir: true }, { path: '.codex/hooks.json', required: false, json: true }, { path: 'docs/README.md', required: false }, - // Backup/symlink architecture is a consumer-project concern; the plugin source - // repo uses git instead, so this check is skipped in plugin-dev mode. - { path: '.harness/backup.json', required: true, json: true, skipInPluginDev: true }, ]; // The plugin *source* repo is not a consumer install: it ships templates/ and its -// own manifest, and uses git rather than the backup/symlink workflow. Grading it as -// a consumer produces false-positive failures (backup.json, clone/symlink/delete.sh, -// backup clone dir). Detect it by structural markers a consumer project never has. +// own manifest. Grading it as a consumer produces false-positive failures. +// Detect it by structural markers a consumer project never has. export async function isPluginDevRepo(targetDir) { return (await exists(join(targetDir, '.claude-plugin/plugin.json'))) && (await exists(join(targetDir, 'templates'))) && (await exists(join(targetDir, 'bin/harness-team.mjs'))); } -const BACKUP_SCRIPTS = ['clone.sh', 'symlink.sh', 'delete.sh']; export async function runDoctor(ctx) { const json = !!(ctx.flags && ctx.flags.json); @@ -819,13 +814,9 @@ export async function runDoctor(ctx) { line(`harness-team doctor → ${ctx.targetDir}\n`); const pluginDev = await isPluginDevRepo(ctx.targetDir); - if (pluginDev) line(' (plugin-dev repo detected — backup/symlink architecture checks are n/a)\n'); + if (pluginDev) line(' (plugin-dev repo detected — consumer-only checks are skipped)\n'); let fail = 0; for (const c of CHECKS) { - if (pluginDev && c.skipInPluginDev) { - add(c.path, 'skip', 'plugin-dev repo — n/a', `- ${c.path} (plugin-dev repo — n/a)`); - continue; - } const p = join(ctx.targetDir, c.path); const ok = await exists(p); if (!ok) { @@ -880,45 +871,6 @@ export async function runDoctor(ctx) { await checkWiredHooks(ctx, add); - // Harness scripts live in the project root since v0.3+ (consumer projects only). - line(''); - if (pluginDev) { - for (const name of BACKUP_SCRIPTS) add(name, 'skip', 'plugin-dev repo — n/a', `- ${name} (plugin-dev repo — n/a)`); - add('backup clone dir', 'skip', 'plugin-dev repo — n/a', `\nbackup clone dir: n/a (plugin-dev repo)`); - } else { - for (const name of BACKUP_SCRIPTS) { - const p = join(ctx.targetDir, name); - if (!(await exists(p))) { - const lst = await lstat(p).catch(() => null); - if (lst && lst.isSymbolicLink()) { - add(name, 'fail', 'broken symlink — target 없음, run: harness-team sync', - `✗ ${name} (broken symlink — target 없음, run: harness-team sync)`); - fail++; continue; - } - add(name, 'fail', 'missing in project root', `✗ ${name} (missing in project root)`); fail++; continue; - } - const st = await lstat(p); - if (!(st.mode & 0o100)) { add(name, 'fail', 'not executable', `✗ ${name} (not executable)`); fail++; continue; } - add(name, 'pass', 'exec', `✓ ${name} (exec)`); - } - - // Reuse loadBackupDir's resolution (~/{parent,name}/{dir}) so the existence - // probe hits the exact path the scripts target — no re-derivation mismatch. - const backupDir = await loadBackupDir(ctx.targetDir); - if (!backupDir) { - add('backup clone dir', 'fail', 'missing .harness/backup.json', - `\n✗ backup clone dir is not configured (missing .harness/backup.json)`); - fail++; - } else if (!(await exists(backupDir))) { - // Configured but gone — the classic iCloud/Dropbox eviction or a manual move. - add('backup clone dir', 'fail', `configured but missing on disk: ${backupDir} (iCloud/Dropbox eviction? moved?)`, - `\n✗ backup clone dir configured but missing on disk: ${backupDir}\n (iCloud/Dropbox eviction? moved? — restore the folder or re-run harness-team init)`); - fail++; - } else { - add('backup clone dir', 'pass', backupDir, `\nbackup clone dir: ${backupDir}`); - } - } - // External tool healthchecks (missing → - / ⚠️ per EXTERNAL_TOOLS, present → ✓, never fail++). // Run concurrently so a slow/hung tool doesn't serialize the worst-case wait. line('\nexternal tools:'); @@ -1066,14 +1018,10 @@ export async function runDoctor(ctx) { if (json) { const warnCount = warnings; - const skipCount = checks.filter(c => c.status === 'skip').length; const status = fail ? 'error' : (warnCount ? 'warning' : 'success'); - // Make plugin-dev mode legible to an agent parsing the envelope: a green bill - // here means "healthy AND backup checks were intentionally skipped", not the - // same success a consumer project reports. Reflect it in summary + extra.mode. - const okSummary = pluginDev - ? `All checks passed (plugin-dev mode — ${skipCount} backup check(s) skipped)` - : 'All checks passed'; + // Make plugin-dev mode legible to an agent parsing the envelope: consumer-only + // checks are skipped there. Reflect it in summary + extra.mode. + const okSummary = pluginDev ? 'All checks passed (plugin-dev mode)' : 'All checks passed'; // Route each warning to its own remedy — legacy structure → migrate, // spec-gate bypass → create the task properly. A blanket 'migrate' would // misdirect an agent whose only warning is a pointer-shell spec. diff --git a/src/commands/init.mjs b/src/commands/init.mjs index 051774e8..8392c504 100644 --- a/src/commands/init.mjs +++ b/src/commands/init.mjs @@ -1,14 +1,9 @@ -import { basename, resolve } from 'node:path'; -import { mkdir } from 'node:fs/promises'; import { detectStack, resolveStack, KNOWN_STACK_IDS } from '../detect-stack.mjs'; import { planChanges, applyChanges, copyStaticAssets, formatDiff, - loadBackupDir, saveBackupConfig, DEFAULT_BACKUP_PARENT, AI_GITIGNORE_PREVIEW, - cloudSyncPathWarning, } from '../harness.mjs'; import { loadRenderState, saveRenderState } from '../render-state.mjs'; -import { backupAnchor } from '../backup-dir.mjs'; -import { confirm, ask } from '../prompt.mjs'; +import { confirm } from '../prompt.mjs'; import { resolveUsername, saveUsername } from '../user-config.mjs'; import { installPostCommitHook } from '../git-hooks.mjs'; @@ -36,55 +31,6 @@ export async function runInit(ctx) { // 결정만 — 저장은 최종 Apply 뒤(applyChanges 직후). 여기서 쓰면 취소해도 config가 남는다. const pendingUsername = await resolveUsername(ctx.targetDir, ctx.flags); - // Resolve the sibling backup directory: ..//. - // The 3 scripts (clone.sh, symlink.sh, delete.sh) are written INTO the project - // root with BACKUP_DIR embedded at generation time, so running `./clone.sh` - // from the project root syncs CWD into that backup clone directory. - // In a linked git worktree the sibling/name come from the main checkout (backupAnchor) — - // backup.json is committed, so a worktree name saved here would poison every checkout. - const anchor = await backupAnchor(ctx.targetDir); - const projectName = basename(anchor); - let saveConfig = null; - - if (ctx.flags['no-backup']) { - ctx.backupDir = null; - console.log(' backup: disabled'); - } else { - let backupDir = await loadBackupDir(ctx.targetDir); - if (!backupDir) { - const dirFromFlag = ctx.flags['backup-dir']; - const parentFromFlag = ctx.flags['backup-parent']; - if (dirFromFlag) { - backupDir = resolve(dirFromFlag.replace(/^~/, process.env.HOME || '~')); - saveConfig = { dir: backupDir }; - } else { - const answered = ctx.flags.yes - ? (parentFromFlag || DEFAULT_BACKUP_PARENT) - : await ask( - `\nBackup clone parent folder (sibling of project, holds clone.sh/symlink.sh/delete.sh)?`, - { defaultValue: parentFromFlag || DEFAULT_BACKUP_PARENT }, - ); - backupDir = resolve(anchor, '..', answered, projectName); - saveConfig = { parent: answered, name: projectName }; - } - } - ctx.backupDir = backupDir; - console.log(` backup clone dir: ${backupDir}`); - const cloudWarn = cloudSyncPathWarning(ctx.targetDir) || cloudSyncPathWarning(backupDir); - if (cloudWarn) console.log(` ⚠️ ${cloudWarn}`); - } - - // Ask whether to add AI-tool gitignore entries. - if (ctx.flags['gitignore-ai'] !== undefined) { - ctx.addAiGitignore = ctx.flags['gitignore-ai'] === true || ctx.flags['gitignore-ai'] === 'true'; - } else if (!ctx.flags.yes) { - console.log(`\nAI tool .gitignore entries to add:\n`); - console.log(AI_GITIGNORE_PREVIEW.split('\n').map(l => ` ${l}`).join('\n')); - ctx.addAiGitignore = await confirm('\nAdd these AI tool entries to .gitignore?', { defaultYes: false }); - } else { - ctx.addAiGitignore = false; - } - const { changes, legacyAgentFiles, brokenMarkerFiles, skippedSections, renderState } = await planChanges(ctx, { stack, stackPin }); if (legacyAgentFiles && legacyAgentFiles.length) { @@ -118,22 +64,13 @@ export async function runInit(ctx) { // 쓰지도 않은 내용을 "우리 렌더"로 믿는다. if (!ok) { console.log('Aborted.'); return; } - if (ctx.backupDir) await mkdir(ctx.backupDir, { recursive: true }); await applyChanges(changes); if (pendingUsername) await saveUsername(ctx.targetDir, pendingUsername); await saveRenderState(ctx.targetDir, renderState); - if (saveConfig) await saveBackupConfig(ctx.targetDir, saveConfig); const copied = await copyStaticAssets(ctx); await installPostCommitHook(ctx.targetDir); console.log(`\n✓ Wrote ${changes.length} merged file(s)`); - if (ctx.backupDir) { - console.log(`✓ Backup clone dir ready: ${ctx.backupDir}`); - console.log(`\nDone. From the project root, run the backup scripts:`); - console.log(` ./clone.sh # sync this project into the backup dir`); - console.log(` ./symlink.sh # create backup → project symlinks`); - console.log(` ./delete.sh # tear down symlinks`); - } console.log(`✓ Copied ${copied.filter(c => c.action === 'write').length} asset(s) (${copied.filter(c => c.action === 'skip').length} skipped as existing)`); console.log(`✓ Agent files: AGENTS.md (core) + CLAUDE.md (@AGENTS.md import)`); } diff --git a/src/commands/migrate.mjs b/src/commands/migrate.mjs index e8f03a89..8dc30c73 100644 --- a/src/commands/migrate.mjs +++ b/src/commands/migrate.mjs @@ -3,7 +3,7 @@ import { constants } from 'node:fs'; import { createHash } from 'node:crypto'; import { unlink, rmdir, readdir, mkdir, lstat, stat, access } from 'node:fs/promises'; import { readTextSafe, writeText, exists } from '../fsx.mjs'; -import { appendGitignore, loadBackupDir, mergeClaudeSettings, settingsHasBoundaryCheckpoint, mirrorCursorRules, AGENT_FILE_TEMPLATES, isLegacyCodexSessionCommand, withCodexHookFlag } from '../harness.mjs'; +import { appendGitignore, mergeClaudeSettings, settingsHasBoundaryCheckpoint, mirrorCursorRules, AGENT_FILE_TEMPLATES, isLegacyCodexSessionCommand, withCodexHookFlag } from '../harness.mjs'; import { resolveStack } from '../detect-stack.mjs'; import { loadRenderState } from '../render-state.mjs'; import { extractSections, deepMergeJson, simpleDiff } from '../merge.mjs'; @@ -21,14 +21,13 @@ import { SUMMARY_REL, userIndexRel, docsPath, taskDirRel, taskFilePath } from '. const USER_REGION_RE = /[\s\S]*?/; const EMPTY_USER_REGION = '\n\n'; -const SCRIPT_FILES = ['clone.sh', 'symlink.sh', 'delete.sh']; const OLD_CATEGORIES = ['feature', 'fix']; // --- Task structure migration (pre-0.6.0 → 0.6.0) --- // // 여기부터 migrateTaskTo07 까지는 **옛 구조를 서술하는** 코드라 src/task-paths.mjs 를 경유하지 않는다 — // 입력이 헬퍼가 모르는 모양(`docs//{feature,fix}/`, 0.6 handoff)이다. 이 구간은 동결돼 있고, -// tests/task-paths-single-source.test.mjs 가 이 표지부터 아래 Backup 표지까지를 허용 구간으로 본다. +// tests/task-paths-single-source.test.mjs 가 이 표지부터 아래 Refresh 표지까지를 허용 구간으로 본다. async function findOldTasks(targetDir) { const docs = join(targetDir, 'docs'); @@ -248,98 +247,6 @@ export async function migrateTaskTo07(ctx) { return true; } -// --- Backup dir script migration (pre-v0.3 → v0.3+) --- - -async function migrateBackupScripts(ctx) { - const { root, targetDir } = ctx; - - const backupDir = await loadBackupDir(targetDir); - if (!backupDir) { - console.log(' backup scripts: no backup dir configured — skipping'); - return false; - } - - const toMigrate = []; - for (const f of SCRIPT_FILES) { - const content = await readTextSafe(join(backupDir, f)); - if (content !== null) { - toMigrate.push({ f, inBackup: join(backupDir, f), inProject: join(targetDir, f) }); - } - } - - if (toMigrate.length === 0) { - console.log(' backup scripts: up to date (no scripts in backup dir)'); - return false; - } - - console.log(`\nFound ${toMigrate.length} script(s) in backup dir to move to project root:`); - for (const { f } of toMigrate) console.log(` ${f}`); - console.log(` backup: ${backupDir}`); - console.log(` project: ${targetDir}`); - - const ok = ctx.flags.yes || await confirm('\nMove scripts to project root?', { defaultYes: true }); - if (!ok) { console.log('Skipped script migration.'); return false; } - - const tplDir = join(root, 'templates'); - for (const { f, inBackup, inProject } of toMigrate) { - const tpl = await readTextSafe(join(tplDir, f)); - if (!tpl) { - console.warn(` warn: template not found for ${f}, skipping`); - continue; - } - const rendered = tpl.replace(/\{\{BACKUP_DIR\}\}/g, backupDir); - await writeText(inProject, rendered, { mode: 0o755 }); - await unlink(inBackup); - console.log(` ✓ ${f}: moved to project root`); - } - - return true; -} - -// --- Refresh stale project-root scripts (any → current template) --- - -async function refreshProjectScripts(ctx) { - const { root, targetDir } = ctx; - - const backupDir = await loadBackupDir(targetDir); - if (!backupDir) { - console.log(' script refresh: no backup dir configured — skipping'); - return false; - } - - const tplDir = join(root, 'templates'); - const stale = []; - for (const f of SCRIPT_FILES) { - const existing = await readTextSafe(join(targetDir, f)); - if (existing === null) continue; - const tpl = await readTextSafe(join(tplDir, f)); - if (!tpl) continue; - const rendered = tpl.replace(/\{\{BACKUP_DIR\}\}/g, backupDir); - if (existing !== rendered) stale.push({ f, rendered }); - } - - if (stale.length === 0) { - console.log(' script refresh: scripts are up to date'); - return false; - } - - console.log(`\nFound ${stale.length} stale script(s) in project root (old destructive 'rm -rf' versions):`); - for (const { f } of stale) console.log(` ${f}`); - console.log('\nRefresh will replace them with the current safe templates:'); - console.log(' - delete.sh: backup symlink만 제거, 실파일은 skip'); - console.log(' - symlink.sh: 실파일이 백업과 동일할 때만 교체, 다르면 skip'); - console.log(' - clone.sh: rsync --update (백업 파일 삭제 없음)'); - - const ok = ctx.flags.yes || await confirm('\nRefresh scripts to current safe templates?', { defaultYes: true }); - if (!ok) { console.log('Skipped script refresh.'); return false; } - - for (const { f, rendered } of stale) { - await writeText(join(targetDir, f), rendered, { mode: 0o755 }); - console.log(` ✓ refreshed: ${f}`); - } - return true; -} - // --- Refresh installed .claude assets (known stock versions → current template) --- // // Template fixes never reach an existing install on their own: copyStaticAssets copies @@ -1103,8 +1010,6 @@ export async function runMigrate(ctx) { const taskMigrated = await migrateTaskStructure(ctx); const taskUpgraded = await migrateTaskTo07(ctx); - const scriptMoved = await migrateBackupScripts(ctx); - const scriptRefreshed = await refreshProjectScripts(ctx); const claudeHooksRefreshed = await refreshClaudeHooks(ctx); const claudeTemplatesRefreshed = await refreshClaudeTemplates(ctx); const taskLabelsRenamed = await migrateTaskIndexLabels(ctx); @@ -1121,13 +1026,10 @@ export async function runMigrate(ctx) { return; } - if (!managedBackedUp && !agentsMigrated && !taskMigrated && !taskUpgraded && !scriptMoved && !scriptRefreshed && !claudeHooksRefreshed && !claudeTemplatesRefreshed && !taskLabelsRenamed && !hookMigrated && !boundaryHookMigrated && !metaBackfilled && !reviewsAdopted && !codexFlagMigrated && !userHandoffUntrack) { + if (!managedBackedUp && !agentsMigrated && !taskMigrated && !taskUpgraded && !claudeHooksRefreshed && !claudeTemplatesRefreshed && !taskLabelsRenamed && !hookMigrated && !boundaryHookMigrated && !metaBackfilled && !reviewsAdopted && !codexFlagMigrated && !userHandoffUntrack) { console.log('\nNothing to migrate — project is already up to date.'); return; } console.log('\n✓ Migration complete.'); - if (scriptMoved || scriptRefreshed) { - console.log(' Run ./clone.sh, ./symlink.sh, ./delete.sh from the project root.'); - } } diff --git a/src/commands/symlink.mjs b/src/commands/symlink.mjs deleted file mode 100644 index ff2b7b81..00000000 --- a/src/commands/symlink.mjs +++ /dev/null @@ -1,110 +0,0 @@ -import { join, resolve } from 'node:path'; -import { lstat, readlink, readdir, readFile, rm, symlink as makeSymlink } from 'node:fs/promises'; -import { resolveBackupDir } from '../backup-dir.mjs'; -import { confirm } from '../prompt.mjs'; - -async function sameTree(a, b) { - const sa = await lstat(a).catch(() => null); - const sb = await lstat(b).catch(() => null); - if (!sa || !sb) return false; - if (sa.isDirectory() && sb.isDirectory()) { - const [ea, eb] = await Promise.all([readdir(a), readdir(b)]); - const ah = [...ea].sort(); - const bh = [...eb].sort(); - if (ah.length !== bh.length) return false; - for (let i = 0; i < ah.length; i++) { - if (ah[i] !== bh[i]) return false; - if (!await sameTree(join(a, ah[i]), join(b, bh[i]))) return false; - } - return true; - } - if (sa.isFile() && sb.isFile()) { - if (sa.size !== sb.size) return false; - const [ca, cb] = await Promise.all([readFile(a), readFile(b)]); - return ca.equals(cb); - } - return false; -} - -const MOVE_ITEMS = ['CLAUDE.md', 'AGENTS.md', '.claude', '.cursor', '.codex', 'docs', '.harness']; -const ALIAS_ITEMS = []; -const SCRIPT_ITEMS = ['clone.sh', 'delete.sh']; - -export async function runSymlink(ctx) { - const backupDir = await resolveBackupDir(ctx.targetDir, { backupDir: ctx.flags['backup-dir'] }); - if (!backupDir) { - console.error('No backup dir found. Run `harness-team init` or `harness-team backup` first.'); - process.exit(1); - } - - const allItems = [...MOVE_ITEMS, ...ALIAS_ITEMS]; - for (const s of SCRIPT_ITEMS) { - if (await lstat(join(backupDir, s)).catch(() => null)) allItems.push(s); - } - - console.log(`harness-team symlink → ${ctx.targetDir}`); - console.log(` backup dir: ${backupDir}`); - - const ops = []; - for (const item of allItems) { - const backup = join(backupDir, item); - if (!await lstat(backup).catch(() => null)) { - ops.push({ item, kind: 'skip', reason: 'not in backup' }); - continue; - } - - const link = join(ctx.targetDir, item); - const linkSt = await lstat(link).catch(() => null); - - if (!linkSt) { - ops.push({ item, kind: 'link', link, backup }); - } else if (linkSt.isSymbolicLink()) { - const raw = await readlink(link); - const resolved = resolve(ctx.targetDir, raw); - if (resolved === backup) { - ops.push({ item, kind: 'skip', reason: 'already linked' }); - } else { - ops.push({ item, kind: 'replace', link, backup }); - } - } else { - if (await sameTree(link, backup)) { - ops.push({ item, kind: 'replace', link, backup, identical: true }); - } else { - ops.push({ - item, - kind: 'skip', - reason: 'real file differs from backup — run `harness-team clone` first, then re-run', - }); - } - } - } - - const skipped = ops.filter(o => o.kind === 'skip' && o.reason && o.reason !== 'not in backup' && o.reason !== 'already linked'); - if (skipped.length > 0) { - console.log('\nSkipped (needs manual action):'); - for (const op of skipped) console.log(` ! ${op.item}: ${op.reason}`); - } - - const toProcess = ops.filter(o => o.kind !== 'skip'); - if (toProcess.length === 0) { - console.log('Nothing to symlink — all already linked or skipped.'); - return; - } - - console.log('\nItems to link:'); - for (const op of toProcess) { - const note = op.identical ? ' (identical → safe replace)' : ''; - console.log(` ${op.kind}: ${op.item} → ${op.backup}${note}`); - } - - const ok = ctx.flags.yes || await confirm('\nProceed?', { defaultYes: true }); - if (!ok) { console.log('Aborted.'); return; } - - for (const op of toProcess) { - if (op.kind === 'replace') await rm(op.link, { recursive: true, force: true }); - await makeSymlink(op.backup, op.link); - console.log(` ✓ linked: ${op.item} → ${op.backup}`); - } - - console.log('\n✓ Symlink complete.'); -} diff --git a/src/commands/upgrade.mjs b/src/commands/upgrade.mjs deleted file mode 100644 index f84f645b..00000000 --- a/src/commands/upgrade.mjs +++ /dev/null @@ -1,86 +0,0 @@ -import { join } from 'node:path'; -import { lstat } from 'node:fs/promises'; -import { resolveBackupDir } from '../backup-dir.mjs'; -import { writeText } from '../fsx.mjs'; -import { confirm } from '../prompt.mjs'; -import { runClone } from './clone.mjs'; -import { runDelete } from './delete.mjs'; -import { runSymlink } from './symlink.mjs'; - -const MOVE_ITEMS = ['CLAUDE.md', 'AGENTS.md', '.claude', '.cursor', '.codex', 'docs', '.harness']; -const ALIAS_ITEMS = []; - -export async function runUpgrade(ctx) { - console.log(`harness-team upgrade → ${ctx.targetDir}`); - - // 1. Resolve backup dir (tilde expansion handled inside resolveBackupDir) - const backupDir = await resolveBackupDir(ctx.targetDir, { backupDir: ctx.flags['backup-dir'] }); - if (!backupDir) { - console.error( - 'No backup dir found.\n' + - ' Option A: harness-team upgrade --backup-dir \n' + - ' Option B: harness-team init (new project setup)' - ); - process.exit(1); - } - console.log(` backup dir: ${backupDir}`); - - // 2. Clone project → backup (creates backup if missing, merges if exists) - console.log('\n[1/3] Syncing project files to backup dir...'); - await runClone({ ...ctx, flags: { ...ctx.flags, yes: true, 'backup-dir': backupDir }, targetDir: ctx.targetDir }); - - // 3. Detect real (non-symlink) items in project root - const realItems = []; - - for (const item of [...MOVE_ITEMS, ...ALIAS_ITEMS]) { - const p = join(ctx.targetDir, item); - const st = await lstat(p).catch(() => null); - if (!st) continue; - if (!st.isSymbolicLink()) { - realItems.push({ item, p }); - } - } - - if (realItems.length === 0) { - console.log('\nNo real files detected — already migrated. Running symlink...'); - await runSymlink({ ...ctx, flags: { ...ctx.flags, 'backup-dir': backupDir } }); - return; - } - - // 4. Confirm with the user - console.log('\n[2/3] Real files/dirs to replace with symlinks:'); - for (const op of realItems) console.log(` ${op.item}`); - - const ok = ctx.flags.yes || await confirm( - '\nDelete these and replace with symlinks from backup? (backup was just synced)', - { defaultYes: false }, - ); - if (!ok) { console.log('Aborted.'); return; } - - // 5. Delete real items; --include-real returns savedBackupConfig - console.log('\n[3/3] Replacing real files with symlinks...'); - const { savedBackupConfig } = await runDelete({ - ...ctx, - flags: { ...ctx.flags, yes: true, 'include-real': true, 'backup-dir': backupDir }, - }); - - // 6. Restore .harness/backup.json so symlink step can find the backup dir - if (savedBackupConfig) { - await writeText( - join(ctx.targetDir, '.harness/backup.json'), - JSON.stringify(savedBackupConfig, null, 2) + '\n', - ); - console.log(' ✓ restored .harness/backup.json'); - } else { - await writeText( - join(ctx.targetDir, '.harness/backup.json'), - JSON.stringify({ dir: backupDir }, null, 2) + '\n', - ); - console.log(' ✓ wrote .harness/backup.json'); - } - - // 7. Create symlinks - await runSymlink({ ...ctx, flags: { ...ctx.flags, yes: true, 'backup-dir': backupDir } }); - - console.log('\n✓ Upgrade complete — project is now on symlink structure.'); -} diff --git a/src/harness.mjs b/src/harness.mjs index 502fed19..8b556999 100644 --- a/src/harness.mjs +++ b/src/harness.mjs @@ -3,14 +3,11 @@ import { join, basename } from 'node:path'; import { lstat, unlink } from 'node:fs/promises'; import { writeText, readTextSafe, copyTree, exists } from './fsx.mjs'; import { render } from './render.mjs'; -import { backupDirFromConfig } from './backup-dir.mjs'; import { mergeMarkdown, deepMergeJson, simpleDiff } from './merge.mjs'; import { stackPermissions, RN_STACK_IDS } from './settings-permissions.mjs'; import { loadRenderState, sectionHashes, readHarnessVersion } from './render-state.mjs'; import { USER_HANDOFF_IGNORE } from './task-paths.mjs'; -export const DEFAULT_BACKUP_PARENT = 'harness-backup'; - // AGENTS.md (shared core) + CLAUDE.md (thin, @AGENTS.md import). // Exported so drift checks stay in step with what init actually renders. export const AGENT_FILE_TEMPLATES = [ @@ -135,34 +132,6 @@ export function mergeClaudeSettings(existing, incoming) { return merged; } -// The symlink-backup architecture breaks when a cloud sync service evicts files -// (offloads to cloud-only). Warn when a target/backup path lives under a known -// sync folder. Returns a warning string, or null. Best-effort by path substring. -export function cloudSyncPathWarning(p) { - if (!p) return null; - const markers = [ - { re: /\/Library\/Mobile Documents\//, name: 'iCloud Drive' }, - { re: /\/Dropbox(\/|$)/, name: 'Dropbox' }, - { re: /\/Google ?Drive(\/|$)/, name: 'Google Drive' }, - { re: /\/OneDrive[^/]*(\/|$)/, name: 'OneDrive' }, - ]; - const hit = markers.find(m => m.re.test(p)); - if (!hit) return null; - return `경로가 ${hit.name} 동기화 폴더 안에 있습니다 — 클라우드가 파일을 evict하면 symlink/백업이 깨질 수 있습니다. 로컬 경로 사용을 권장합니다.`; -} - -// Resolve the backup directory from a stored config, else null. -export async function loadBackupDir(targetDir) { - const cfg = await readTextSafe(join(targetDir, '.harness/backup.json')); - if (!cfg) return null; - try { return await backupDirFromConfig(targetDir, JSON.parse(cfg)); } catch { return null; } -} - -export async function saveBackupConfig(targetDir, config) { - await writeText(join(targetDir, '.harness/backup.json'), - JSON.stringify(config, null, 2) + '\n'); -} - // stackPin: render-state에 남길 강제 스택 id(init이 정한다). 없으면 필드를 쓰지 않는다 = 자동 감지. export async function planChanges(ctx, { stack, stackPin }) { const { root, targetDir } = ctx; @@ -236,18 +205,6 @@ export async function planChanges(ctx, { stack, stackPin }) { if (Object.keys(carried).length) renderState.sections[file] = carried; } - // Scripts live in the project root with the backup dir path embedded at generation time. - const backupDir = ctx.backupDir; - if (backupDir) { - for (const f of ['clone.sh', 'symlink.sh', 'delete.sh']) { - const tpl = await readTextSafe(join(tplDir, f)); - if (!tpl) continue; - const rendered = tpl.replace(/\{\{BACKUP_DIR\}\}/g, backupDir); - const existing = await readTextSafe(join(targetDir, f)); - if (existing !== rendered) changes.push({ kind: 'script', path: join(targetDir, f), after: rendered }); - } - } - // .claude/settings.json — JSON deep-merge const tplSettings = JSON.parse(await readTextSafe(join(tplDir, '.claude/settings.json'))); // permissions의 pm·RN 의존 항목은 템플릿에 없다 — 스택 프로필에서 생성해 합성한다. @@ -341,52 +298,20 @@ export async function copyStaticAssets(ctx) { out.push({ path: activePath, action: 'write' }); } // .gitignore tweaks - await appendGitignore(ctx.targetDir, { addAiEntries: !!ctx.addAiGitignore }); + await appendGitignore(ctx.targetDir); // cursor rules — mirrored from .claude/rules await mirrorCursorRules(ctx); return out; } -const AI_GITIGNORE_ENTRIES = [ - '# AI', - 'CLAUDE.md', - 'AGENTS.md', - '', - 'handoff.md', - 'plan.md', - '', - '.claude', - '.claude/', - '.cursor', - '.cursor/', - '.omc', - '.omc/', - '.omx', - '.omx/', - '.ai', - '.ai/', - '.sisyphus', - '.sisyphus/', - '.agents', - '.agents/', - '.cursorrules', - '.codex', - '.codex/', - '', - '# build', - 'output/', - '', - '*.log', -]; - -export async function appendGitignore(targetDir, { addAiEntries = false } = {}) { +export async function appendGitignore(targetDir) { const { readTextSafe, writeText } = await import('./fsx.mjs'); const path = join(targetDir, '.gitignore'); const existing = (await readTextSafe(path)) ?? ''; const lines = existing.split('\n'); const has = (line) => lines.some(l => l.trim() === line); - // Not `.harness/` wholesale: backup.json (the shared backup path), the cursor mirror + // Not `.harness/` wholesale: the cursor mirror // manifest and render-state.json are team state the README asks teams to commit. Only the // per-user pointer/config, the observability logs and the local managed-section backups // are personal. `.harness/backup/` holds verbatim copies of the team's own AGENTS.md / @@ -408,20 +333,11 @@ export async function appendGitignore(targetDir, { addAiEntries = false } = {}) out += `\n# harness-aijient-team\n${harnessMissing.join('\n')}\n`; } - if (addAiEntries) { - const aiMissing = AI_GITIGNORE_ENTRIES.filter(line => line === '' || !has(line)); - if (aiMissing.length > 0) { - out += `\n${aiMissing.join('\n')}\n`; - } - } - if (out === existing) return false; await writeText(path, out); return true; } -export const AI_GITIGNORE_PREVIEW = AI_GITIGNORE_ENTRIES.join('\n'); - // `.claude/rules/*.md` scopes a rule with a `paths:` glob list; Claude Code then // loads it only while working with matching files. Cursor expresses the same // intent as a comma-separated `globs:` string, so the mirror must translate — diff --git a/templates/clone.sh b/templates/clone.sh deleted file mode 100755 index 6cbe218f..00000000 --- a/templates/clone.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash -# Syncs non-symlink items from this project to the harness backup directory. -# Run from the project root. -set -euo pipefail - -BACKUP_DIR="{{BACKUP_DIR}}" -ITEMS=("CLAUDE.md" "AGENTS.md" ".claude" ".cursor" ".codex" "docs" ".harness") - -if [ ! -d "$BACKUP_DIR" ]; then - echo "error: backup dir not found: $BACKUP_DIR" >&2 - exit 1 -fi - -for item in "${ITEMS[@]}"; do - if [ -L "$item" ]; then - target="$(readlink "$item")" - if [[ "$target" == "$BACKUP_DIR" || "$target" == "$BACKUP_DIR"/* ]]; then - echo "skip: $item (backup symlink)" - else - ln -sfn "$target" "$BACKUP_DIR/$item" - echo "synced symlink: $item -> $target" - fi - elif [ -d "$item" ]; then - mkdir -p "$BACKUP_DIR/$item" - rsync -a --update "$item/" "$BACKUP_DIR/$item/" - echo "merged dir: $item -> $BACKUP_DIR/$item" - elif [ -e "$item" ]; then - if [ ! -e "$BACKUP_DIR/$item" ] || [ "$item" -nt "$BACKUP_DIR/$item" ]; then - cp -p "$item" "$BACKUP_DIR/$item" - echo "copied (newer): $item -> $BACKUP_DIR/$item" - else - echo "skip: $item (backup is newer or equal)" - fi - else - echo "skip: $item (not found)" - fi -done diff --git a/templates/delete.sh b/templates/delete.sh deleted file mode 100755 index f9e0ca61..00000000 --- a/templates/delete.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/bash -# Removes only symlinks in this project that point into the harness backup directory. -# Never deletes real files/directories — neither in the project nor in the backup. -# To remove real files too, use `harness-team delete --include-real` (interactive). -# Run from the project root. -set -euo pipefail - -BACKUP_DIR="{{BACKUP_DIR}}" -# AGENTS.md is a real file now (not CLAUDE.md aliases); .cursorrules retired. -ITEMS=("CLAUDE.md" "AGENTS.md" ".claude" ".cursor" ".codex" "docs" ".harness") - -for item in "${ITEMS[@]}"; do - if [ -L "$item" ]; then - target="$(readlink "$item")" - # 경로 경계까지 본다 — 접두 매치는 project-a가 project-ab의 링크까지 잡는다. - if [[ "$target" == "$BACKUP_DIR" || "$target" == "$BACKUP_DIR"/* ]]; then - rm "$item" - echo "removed: $item (backup symlink)" - else - echo "skip: $item (local symlink -> $target)" - fi - elif [ -e "$item" ]; then - echo "skip: $item (real file/dir — use 'harness-team delete --include-real' to remove)" - else - echo "skip: $item (not found)" - fi -done diff --git a/templates/symlink.sh b/templates/symlink.sh deleted file mode 100755 index 5ee8005b..00000000 --- a/templates/symlink.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/bin/bash -# Creates symlinks in this project pointing to items in the harness backup directory. -# Never destroys real files in the project or in the backup: -# - if a real file/dir already exists in the project and is byte-identical to the -# backup copy, it is replaced with a symlink -# - if it differs, it is left untouched and the user is told to run clone.sh first -# Run from the project root. -set -euo pipefail - -BACKUP_DIR="{{BACKUP_DIR}}" -ITEMS=("CLAUDE.md" "AGENTS.md" ".claude" ".cursor" ".codex" "docs" ".harness") - -if [ ! -d "$BACKUP_DIR" ]; then - echo "error: backup dir not found: $BACKUP_DIR" >&2 - exit 1 -fi - -same_tree() { - # Returns 0 if $1 and $2 have identical contents (files or directories). - local a="$1" b="$2" - if [ -d "$a" ] && [ -d "$b" ]; then - diff -rq "$a" "$b" >/dev/null 2>&1 - elif [ -f "$a" ] && [ -f "$b" ]; then - cmp -s "$a" "$b" - else - return 1 - fi -} - -for item in "${ITEMS[@]}"; do - backup="$BACKUP_DIR/$item" - if [ ! -e "$backup" ]; then - echo "skip: $item (not found in backup)" - continue - fi - - if [ -L "$item" ]; then - target="$(readlink "$item")" - if [ "$target" = "$backup" ]; then - echo "skip: $item (already linked to backup)" - else - rm "$item" - ln -s "$backup" "$item" - echo "replaced symlink: $item -> $backup" - fi - elif [ -e "$item" ]; then - if same_tree "$item" "$backup"; then - rm -rf "$item" - ln -s "$backup" "$item" - echo "replaced (identical): $item -> $backup" - else - echo "skip: $item (real file differs from backup — run ./clone.sh first, then re-run)" - fi - else - ln -s "$backup" "$item" - echo "linked: $item -> $backup" - fi -done diff --git a/tests/backup-dir.test.mjs b/tests/backup-dir.test.mjs deleted file mode 100644 index f02ff5ef..00000000 --- a/tests/backup-dir.test.mjs +++ /dev/null @@ -1,176 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { resolveBackupDir } from '../src/backup-dir.mjs'; - -test('opts.backupDir override를 즉시 반환', async () => { - const result = await resolveBackupDir('/nonexistent', { backupDir: '/custom/path' }); - assert.equal(result, '/custom/path'); -}); - -test('opts.backupDir tilde를 절대경로로 확장', async () => { - const { homedir } = await import('node:os'); - const result = await resolveBackupDir('/nonexistent', { backupDir: '~/my-backup' }); - assert.equal(result, `${homedir()}/my-backup`); -}); - -test('.harness/backup.json의 dir 필드를 읽음', async () => { - const dir = await mkdtemp(join(tmpdir(), 'harness-test-')); - try { - await mkdir(join(dir, '.harness'), { recursive: true }); - await writeFile(join(dir, '.harness/backup.json'), JSON.stringify({ dir: '/some/backup' })); - assert.equal(await resolveBackupDir(dir), '/some/backup'); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test('../harness-backup/ 디렉토리가 있으면 auto-detect', async () => { - const parent = await mkdtemp(join(tmpdir(), 'harness-parent-')); - const projectDir = join(parent, 'my-project'); - const backupDir = join(parent, 'harness-backup', 'my-project'); - try { - await mkdir(projectDir, { recursive: true }); - await mkdir(backupDir, { recursive: true }); - assert.equal(await resolveBackupDir(projectDir), backupDir); - } finally { - await rm(parent, { recursive: true, force: true }); - } -}); - -test('기존 symlink에서 backup dir 역추적', async () => { - const parent = await mkdtemp(join(tmpdir(), 'harness-parent-')); - const projectDir = join(parent, 'my-project'); - const backupDir = join(parent, 'harness-backup', 'my-project'); - try { - await mkdir(projectDir, { recursive: true }); - await mkdir(backupDir, { recursive: true }); - await writeFile(join(backupDir, 'CLAUDE.md'), '# test'); - // Create CLAUDE.md in project as a symlink pointing into backup - await symlink(join(backupDir, 'CLAUDE.md'), join(projectDir, 'CLAUDE.md')); - assert.equal(await resolveBackupDir(projectDir), backupDir); - } finally { - await rm(parent, { recursive: true, force: true }); - } -}); - -test('아무것도 없으면 null 반환', async () => { - const dir = await mkdtemp(join(tmpdir(), 'harness-test-')); - try { - assert.equal(await resolveBackupDir(dir), null); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -// ── 링크된 git 워크트리 ───────────────────────────────────────────────────────── -// 앱 워크트리(`/.claude/worktrees/`)에서 `{parent,name}`의 `..`가 `.claude/worktrees/`로 풀리던 결함. -// 기대값은 메인 체크아웃 기준이다. tmpdir 은 macOS 에서 /var → /private/var 이므로 realpath 로 맞춘다. -async function worktreeFixture() { - const { realpath } = await import('node:fs/promises'); - const { execFileSync } = await import('node:child_process'); - const parent = await realpath(await mkdtemp(join(tmpdir(), 'harness-wt-'))); - const main = join(parent, 'proj'); - await mkdir(main, { recursive: true }); - const git = (...args) => execFileSync('git', ['-C', main, ...args], { stdio: 'pipe' }); - git('init', '-q'); - git('-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', 'commit', '-q', '--allow-empty', '-m', 'init'); - const wt = join(main, '.claude', 'worktrees', 'w1'); - git('worktree', 'add', '-q', '-b', 'w1', wt); - return { parent, main, wt, cleanup: () => rm(parent, { recursive: true, force: true }) }; -} - -async function writeBackupJson(dir, data) { - await mkdir(join(dir, '.harness'), { recursive: true }); - await writeFile(join(dir, '.harness/backup.json'), JSON.stringify(data)); -} - -test('워크트리: backup.json {parent,name}을 메인 체크아웃 기준으로 푼다 (load·resolve 공통)', async () => { - const { loadBackupDir } = await import('../src/harness.mjs'); - const fx = await worktreeFixture(); - try { - const cfg = { parent: 'harness-backup', name: 'proj' }; - await writeBackupJson(fx.main, cfg); - await writeBackupJson(fx.wt, cfg); - const expected = join(fx.parent, 'harness-backup', 'proj'); - assert.equal(await loadBackupDir(fx.main), expected, '메인 체크아웃 결과는 종전과 같다'); - assert.equal(await loadBackupDir(fx.wt), expected); - assert.equal(await resolveBackupDir(fx.wt), expected); - } finally { - await fx.cleanup(); - } -}); - -test('워크트리: 하위 디렉터리 설치본도 메인 체크아웃의 같은 상대 위치 기준', async () => { - const { loadBackupDir } = await import('../src/harness.mjs'); - const fx = await worktreeFixture(); - try { - const cfg = { parent: 'harness-backup', name: 'pkg' }; - await writeBackupJson(join(fx.wt, 'pkg'), cfg); - const expected = join(fx.main, 'harness-backup', 'pkg'); - assert.equal(await loadBackupDir(join(fx.wt, 'pkg')), expected); - assert.equal(await resolveBackupDir(join(fx.wt, 'pkg')), expected); - } finally { - await fx.cleanup(); - } -}); - -test('워크트리: auto-detect 는 메인 체크아웃 이름으로 ../harness-backup/ 을 찾는다', async () => { - const fx = await worktreeFixture(); - try { - const backupDir = join(fx.parent, 'harness-backup', 'proj'); - await mkdir(backupDir, { recursive: true }); - assert.equal(await resolveBackupDir(fx.wt), backupDir); - } finally { - await fx.cleanup(); - } -}); - -test('backupAnchor: 메인·비-git 은 targetDir 그대로, 워크트리는 메인 체크아웃 쪽 경로', async () => { - const { backupAnchor } = await import('../src/backup-dir.mjs'); - const fx = await worktreeFixture(); - const plain = await mkdtemp(join(tmpdir(), 'harness-nogit-')); - try { - assert.equal(await backupAnchor(fx.main), fx.main); - assert.equal(await backupAnchor(plain), plain); - assert.equal(await backupAnchor(fx.wt), fx.main); - } finally { - await fx.cleanup(); - await rm(plain, { recursive: true, force: true }); - } -}); - -test('backupAnchor: .git 에 둔 bare 저장소의 워크트리는 targetDir 그대로', async () => { - const { backupAnchor } = await import('../src/backup-dir.mjs'); - const { realpath } = await import('node:fs/promises'); - const { execFileSync } = await import('node:child_process'); - const parent = await realpath(await mkdtemp(join(tmpdir(), 'harness-bare-'))); - const git = (...args) => execFileSync('git', args, { cwd: parent, stdio: 'pipe' }); - try { - git('init', '-q', 'src'); - git('-C', 'src', '-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', 'commit', '-q', '--allow-empty', '-m', 'init'); - git('clone', '-q', '--bare', 'src', join('bb', '.git')); - git('-C', join('bb', '.git'), 'worktree', 'add', '-q', join(parent, 'bb', 'wt1')); - const wt = join(parent, 'bb', 'wt1'); - assert.equal(await backupAnchor(wt), wt); - } finally { - await rm(parent, { recursive: true, force: true }); - } -}); - -test('backupAnchor: 상속된 GIT_DIR 을 무시하고 targetDir 로 저장소를 찾는다', async () => { - const { backupAnchor } = await import('../src/backup-dir.mjs'); - const fx = await worktreeFixture(); - const plain = await mkdtemp(join(tmpdir(), 'harness-nogit-')); - const saved = process.env.GIT_DIR; - process.env.GIT_DIR = join(fx.main, '.git', 'worktrees', 'w1'); - try { - assert.equal(await backupAnchor(plain), plain); - } finally { - if (saved === undefined) delete process.env.GIT_DIR; else process.env.GIT_DIR = saved; - await fx.cleanup(); - await rm(plain, { recursive: true, force: true }); - } -}); diff --git a/tests/cli-args.test.mjs b/tests/cli-args.test.mjs index 69f153a7..e6c52da5 100644 --- a/tests/cli-args.test.mjs +++ b/tests/cli-args.test.mjs @@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'; import { readFile } from 'node:fs/promises'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; -import { COMMANDS, GLOBAL_FLAGS, FLAG_ALIASES, resolveInvocation, parseArgs } from '../src/cli-args.mjs'; +import { COMMANDS, GLOBAL_FLAGS, resolveInvocation, parseArgs } from '../src/cli-args.mjs'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const pexec = promisify(execFile); @@ -33,13 +33,13 @@ test('cli-args: every command answers --help instead of running', () => { // invocation would otherwise run the remembered command. test('cli-args: --help wins over the rest of the argv', () => { assert.equal(resolveInvocation(['release', 'major', '--help']).kind, 'help'); - assert.equal(resolveInvocation(['delete', '--include-real', '--help']).kind, 'help'); + assert.equal(resolveInvocation(['migrate', '--adopt-reviews', '--help']).kind, 'help'); }); // A typo in a flag used to be accepted as `true` and the command ran with its -// defaults — on `release` that is a live version bump, on `delete` a removal. +// defaults — on `release` that is a live version bump, on `migrate` an unintended rewrite. test('cli-args: an unknown flag is a usage error, not a default run', () => { - for (const argv of [['release', '--dryrun'], ['release', '--dry_run'], ['delete', '--include-reals']]) { + for (const argv of [['release', '--dryrun'], ['release', '--dry_run'], ['migrate', '--adopt-reviewz'], ['init', '--no-backup'], ['init', '--gitignore-ai']]) { const invocation = resolveInvocation(argv); assert.equal(invocation.kind, 'error', `${argv.join(' ')} must not run`); assert.equal(invocation.code, 2); @@ -114,12 +114,6 @@ test('cli-args: real invocations still resolve to a run', () => { assert.equal(resolveInvocation(['observe', '--days']).kind, 'error'); }); -test('cli-args: --no-gitignore-ai normalizes to the key init reads', () => { - assert.equal(resolveInvocation(['init', '--no-gitignore-ai']).flags['gitignore-ai'], false); - assert.equal(resolveInvocation(['init', '--gitignore-ai']).flags['gitignore-ai'], true); - assert.equal(resolveInvocation(['init']).flags['gitignore-ai'], undefined); -}); - test('cli-args: globals are accepted on every command', () => { for (const command of COMMANDS.filter(c => c.name !== 'help')) { for (const flag of GLOBAL_FLAGS) { @@ -150,7 +144,7 @@ test('cli-args: the command table and the bin router agree', async () => { }); // A flag that is accepted and documented but never read is the same silent -// failure this module exists to remove: the caller passes `--backup-dir B`, +// failure this module exists to remove: the caller passes `--stack B`, // nothing errors, and the command operates on A. Each declared flag must appear // in the module that actually handles the command. const HANDLER = { list: 'task', done: 'task', handoff: 'task', retro: 'task' }; @@ -160,9 +154,8 @@ test('cli-args: every declared flag is read by the command that declares it', as const module = HANDLER[command.name] ?? command.name; const source = await readFile(join(ROOT, 'src/commands', `${module}.mjs`), 'utf8'); for (const declared of command.flags) { - const key = FLAG_ALIASES.get(declared) ?? declared; - const read = source.includes(`flags['${key}']`) || source.includes(`flags.${key}`); - assert.ok(read, `${command.name} accepts --${declared} but ${module}.mjs never reads flags['${key}']`); + const read = source.includes(`flags['${declared}']`) || source.includes(`flags.${declared}`); + assert.ok(read, `${command.name} accepts --${declared} but ${module}.mjs never reads flags['${declared}']`); } } }); diff --git a/tests/delete.test.mjs b/tests/delete.test.mjs deleted file mode 100644 index ddd3d918..00000000 --- a/tests/delete.test.mjs +++ /dev/null @@ -1,61 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtemp, mkdir, writeFile, rm, lstat } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { runDelete } from '../src/commands/delete.mjs'; - -async function makeFixture() { - const parent = await mkdtemp(join(tmpdir(), 'harness-del-')); - const projectDir = join(parent, 'my-project'); - const backupDir = join(parent, 'harness-backup', 'my-project'); - - await mkdir(join(projectDir, '.harness'), { recursive: true }); - await writeFile( - join(projectDir, '.harness/backup.json'), - JSON.stringify({ dir: backupDir }), - ); - await mkdir(backupDir, { recursive: true }); - return { parent, projectDir, backupDir }; -} - -test('--include-real이 없으면 실제 디렉토리는 skip', async () => { - const { parent, projectDir } = await makeFixture(); - try { - await mkdir(join(projectDir, '.claude'), { recursive: true }); - await writeFile(join(projectDir, '.claude/settings.json'), '{}'); - await runDelete({ targetDir: projectDir, flags: { yes: true } }); - // .claude is a real dir, should NOT be deleted without --include-real - await lstat(join(projectDir, '.claude')); // must still exist → no throw - } finally { - await rm(parent, { recursive: true, force: true }); - } -}); - -test('--include-real이면 실제 디렉토리도 삭제', async () => { - const { parent, projectDir } = await makeFixture(); - try { - await mkdir(join(projectDir, '.claude'), { recursive: true }); - await writeFile(join(projectDir, '.claude/settings.json'), '{}'); - await runDelete({ targetDir: projectDir, flags: { yes: true, 'include-real': true } }); - // .claude should be deleted - const st = await lstat(join(projectDir, '.claude')).catch(() => null); - assert.equal(st, null); - } finally { - await rm(parent, { recursive: true, force: true }); - } -}); - -test('--include-real: .harness 삭제 후 backup.json 내용 반환', async () => { - const { parent, projectDir, backupDir } = await makeFixture(); - try { - // .harness is a real dir (already created in makeFixture) - const result = await runDelete({ targetDir: projectDir, flags: { yes: true, 'include-real': true } }); - const st = await lstat(join(projectDir, '.harness')).catch(() => null); - assert.equal(st, null); - // result should contain the saved backup config - assert.ok(result?.savedBackupConfig?.dir === backupDir); - } finally { - await rm(parent, { recursive: true, force: true }); - } -}); diff --git a/tests/detect-stack.test.mjs b/tests/detect-stack.test.mjs index 57478f7c..03c2fba1 100644 --- a/tests/detect-stack.test.mjs +++ b/tests/detect-stack.test.mjs @@ -71,7 +71,7 @@ test('init --stack 은 아무것도 쓰지 않고 exit 2로 거부한 const dir = await project({ 'package.json': { name: 'a' } }); try { const r = await new Promise((res) => { - const child = spawn(process.execPath, [BIN, 'init', '--yes', '--no-backup', '--stack', 'reakt'], { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'] }); + const child = spawn(process.execPath, [BIN, 'init', '--yes', '--stack', 'reakt'], { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = '', stderr = ''; child.stdout.on('data', d => { stdout += d; }); child.stderr.on('data', d => { stderr += d; }); @@ -90,7 +90,7 @@ test('init --stack 은 아무것도 쓰지 않고 exit 2로 거부한 test('init --stack <감지와 다른 id>는 고정되어 플래그 없는 init이 유지하고, --stack <감지 id>로 푼다', async () => { const dir = await project({ 'package.json': { name: 'a', scripts: { test: 'node --test' } } }); const init = (...args) => new Promise((res, rej) => { - const child = spawn(process.execPath, [BIN, 'init', '--yes', '--no-backup', ...args], { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'] }); + const child = spawn(process.execPath, [BIN, 'init', '--yes', ...args], { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'] }); let out = ''; child.stdout.on('data', d => { out += d; }); child.stderr.on('data', d => { out += d; }); diff --git a/tests/doctor.test.mjs b/tests/doctor.test.mjs index 17914e57..5ec07cb4 100644 --- a/tests/doctor.test.mjs +++ b/tests/doctor.test.mjs @@ -11,7 +11,6 @@ import { render } from '../src/render.mjs'; import { detectStack } from '../src/detect-stack.mjs'; import { sectionHashes } from '../src/render-state.mjs'; import { POST_COMMIT_HOOK } from '../src/git-hooks.mjs'; -import { cloudSyncPathWarning } from '../src/harness.mjs'; import { taskSpecTemplate } from '../src/commands/task.mjs'; import { observeToolEvent } from '../templates/.claude/hooks/observe-tools.mjs'; import { OBSERVABILITY_BASE } from '../src/commands/observe.mjs'; @@ -622,30 +621,14 @@ test('isPluginDevRepo: 마커 하나라도 빠지면 false (소비자 프로젝 } finally { await rm(dir, { recursive: true, force: true }); } }); -test('cloudSyncPathWarning: iCloud/Dropbox/Google Drive/OneDrive 경로 → 경고', () => { - assert.match(cloudSyncPathWarning('/Users/x/Library/Mobile Documents/iCloud~md~obsidian/p'), /iCloud/); - assert.match(cloudSyncPathWarning('/Users/x/Dropbox/p'), /Dropbox/); - assert.match(cloudSyncPathWarning('/Users/x/Google Drive/p'), /Google Drive/); - assert.match(cloudSyncPathWarning('/Users/x/OneDrive-Corp/p'), /OneDrive/); -}); - -test('cloudSyncPathWarning: 로컬 경로/빈값 → null', () => { - assert.equal(cloudSyncPathWarning('/Users/x/projects/p'), null); - assert.equal(cloudSyncPathWarning(''), null); - assert.equal(cloudSyncPathWarning(null), null); -}); - // --- runDoctor integration (real CLI) — guards item 5/6 branching that the pure // helper tests don't reach. Mirrors the manual --json checks used in dev. --- -test('runDoctor: 플러그인 소스 레포 → plugin-dev 모드, backup 체크 skip, fail 0', async () => { +test('runDoctor: 플러그인 소스 레포 → plugin-dev 모드, fail 0', async () => { const env = await doctorJson(ROOT); assert.equal(env.mode, 'plugin-dev', 'top-level mode must flag plugin-dev'); const failCount = (env.checks || []).filter(c => c.status === 'fail').length; assert.equal(failCount, 0, `plugin-dev repo must have 0 fails, got ${failCount}`); - const skipCount = (env.checks || []).filter(c => c.status === 'skip').length; - assert.ok(skipCount >= 5, `expected ≥5 skipped backup checks, got ${skipCount}`); - assert.equal(checkOf(env, '.harness/backup.json')?.status, 'skip', 'backup.json check must be skipped, not failed'); // Consumer-only: plugin-dev runs `node bin/harness-team.mjs` and installs no consumer // hooks, so a PATH miss here would be a false alarm rather than a real breakage. assert.equal(checkOf(env, 'SessionStart/post-commit hook CLI')?.status, 'skip', @@ -664,15 +647,12 @@ test('runDoctor: 깨진(dangling) symlink → "broken symlink"로 구분 fail', } finally { await rm(dir, { recursive: true, force: true }); } }); -test('runDoctor: backup dir이 설정됐지만 디스크에 없으면 fail (iCloud eviction)', async () => { - const dir = await mkdtemp(join(tmpdir(), 'harness-doctor-nobackup-')); +test('runDoctor: .harness/backup.json 이 없는 신규 설치도 fail·warning 없이 backup 검사를 하지 않는다', async () => { + const dir = await healthyConsumerFixture(); try { - await mkdir(join(dir, '.harness'), { recursive: true }); - await writeFile(join(dir, '.harness/backup.json'), JSON.stringify({ dir: '/tmp/harness-definitely-absent-xyz' })); - const env = await doctorJson(dir); - const c = checkOf(env, 'backup clone dir'); - assert.equal(c?.status, 'fail'); - assert.match(c.detail, /missing on disk/); + const env = await doctorJson(dir, noJqEnvFor(dir)); + assert.equal((env.checks || []).filter(c => c.status === 'fail').length, 0); + assert.equal((env.checks || []).filter(c => /backup/i.test(c.label)).length, 0, 'backup 관련 검사 항목이 없어야 한다'); } finally { await rm(dir, { recursive: true, force: true }); } }); @@ -738,13 +718,7 @@ async function healthyConsumerFixture(hooks = {}) { await writeFile(join(dir, 'CLAUDE.md'), '@AGENTS.md\n'); await mkdir(join(dir, '.claude/hooks'), { recursive: true }); await writeFile(join(dir, '.claude/settings.json'), '{}\n'); - for (const name of ['clone.sh', 'symlink.sh', 'delete.sh']) { - await writeFile(join(dir, name), '#!/bin/sh\n', { mode: 0o755 }); - } - const backup = join(dir, 'backup-clone'); - await mkdir(backup, { recursive: true }); await mkdir(join(dir, '.harness'), { recursive: true }); - await writeFile(join(dir, '.harness/backup.json'), JSON.stringify({ dir: backup })); for (const [name, body] of Object.entries(hooks)) { await writeFile(join(dir, '.claude/hooks', name), body, { mode: 0o755 }); } @@ -1208,7 +1182,7 @@ test('findStaleManagedSections: init --stack 으로 감지와 다른 스택을 try { await writeFile(join(dir, 'package.json'), JSON.stringify({ name: 'a', scripts: { test: 'node --test' } })); assert.equal((await detectStack(dir)).id, 'node', '전제: 감지 스택은 node'); - await pexec('node', [join(ROOT, 'bin/harness-team.mjs'), 'init', '--yes', '--no-backup', '--stack', 'next'], { cwd: dir, timeout: 20000 }); + await pexec('node', [join(ROOT, 'bin/harness-team.mjs'), 'init', '--yes', '--stack', 'next'], { cwd: dir, timeout: 20000 }); assert.match(await readFile(join(dir, 'AGENTS.md'), 'utf8'), /Next\.js/, '전제: 강제 스택으로 렌더됨'); assert.deepEqual(await findStaleManagedSections(dir, ROOT), []); } finally { await rm(dir, { recursive: true, force: true }); } @@ -1250,7 +1224,7 @@ test('init 이 harnessVersion 을 기록하고 doctor 가 CLI 보다 낡은 기 const dir = join(base, 'p'); try { await mkdir(dir); - await pexec('node', [join(ROOT, 'bin/harness-team.mjs'), 'init', '--yes', '--backup-dir', join(base, 'bk')], { cwd: dir, timeout: 20000 }); + await pexec('node', [join(ROOT, 'bin/harness-team.mjs'), 'init', '--yes'], { cwd: dir, timeout: 20000 }); const { version } = JSON.parse(await readFile(join(ROOT, 'package.json'), 'utf8')); const statePath = join(dir, '.harness/render-state.json'); const state = JSON.parse(await readFile(statePath, 'utf8')); diff --git a/tests/documentation-inventory-pointers.test.mjs b/tests/documentation-inventory-pointers.test.mjs index 5c0e4c47..78231648 100644 --- a/tests/documentation-inventory-pointers.test.mjs +++ b/tests/documentation-inventory-pointers.test.mjs @@ -35,10 +35,9 @@ test('README points to SSOTs while overview exposes generated inventories', asyn assert.doesNotMatch(readme, /Claude의 17개/); assert.ok(manifest.commands.every((entry) => commandFiles.includes(entry.replace('./commands/', '')))); - assert.match(readme, /\.harness\/backup\.json`은 팀이 공유하는 설정이므로 commit을 권장합니다/); assert.match(readme, /harness-team release /); assert.match(readme, /수동 절차/); - assert.match(readme, /각 wrapper는 `commands\/harness-\*\.md`를 SSOT로 읽습니다\. 단, `harness-sim`은 방향이 반대로/); + assert.match(readme, /각 wrapper는 `commands\/harness-\*\.md`를 SSOT로 읽습니다\./); const commands = section(overview, 'commands'); const task = section(overview, 'task'); diff --git a/tests/e2e/init-smoke.test.mjs b/tests/e2e/init-smoke.test.mjs index 6d332b0d..a73cd9b2 100644 --- a/tests/e2e/init-smoke.test.mjs +++ b/tests/e2e/init-smoke.test.mjs @@ -9,7 +9,6 @@ import { STACKS, appliedSandbox } from './sandbox.mjs'; const REQUIRED_PATHS = [ 'AGENTS.md', 'CLAUDE.md', - 'clone.sh', 'symlink.sh', 'delete.sh', '.claude/settings.json', '.claude/hooks/protect-files.sh', '.claude/hooks/block-dangerous-git.sh', @@ -18,7 +17,6 @@ const REQUIRED_PATHS = [ '.claude/hooks/pre-commit-check.sh', '.claude/hooks/observe-tools.mjs', '.codex/hooks.json', - '.harness/backup.json', '.harness/active.json', 'docs/README.md', '.git/hooks/post-commit', diff --git a/tests/e2e/sandbox.mjs b/tests/e2e/sandbox.mjs index 451b72b5..47f9dd31 100644 --- a/tests/e2e/sandbox.mjs +++ b/tests/e2e/sandbox.mjs @@ -79,12 +79,9 @@ export async function createSandbox(stack) { return { dir, env, cli, gitCommit, cleanup }; } -// Sandbox with the harness already installed non-interactively. backup-dir is kept -// inside the sandbox so doctor's required `.harness/backup.json` check passes. +// Sandbox with the harness already installed non-interactively. export async function appliedSandbox(stack) { const sb = await createSandbox(stack); - const initResult = await sb.cli([ - 'init', '--yes', '--backup-dir', join(sb.dir, '.hbackup'), - ]); + const initResult = await sb.cli(['init', '--yes']); return { ...sb, initResult }; } diff --git a/tests/gitignore-entries.test.mjs b/tests/gitignore-entries.test.mjs index e05f24a7..af80b754 100644 --- a/tests/gitignore-entries.test.mjs +++ b/tests/gitignore-entries.test.mjs @@ -1,34 +1,29 @@ // `.gitignore` used to receive `.harness/` wholesale, contradicting the README that asks -// teams to commit `.harness/backup.json` (the shared backup path). Only the per-user -// pointer/config and the observability logs are personal. AI-tool entries no longer -// list GEMINI.md / opencode.json / .opencode (D7). +// teams to commit the shared harness state (render-state.json, cursor-mirror.json). Only the +// per-user pointer/config, the observability logs and the local managed-section backups are +// personal. init no longer offers an "AI tool entries" gitignore option: it must not add any. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { copyStaticAssets, AI_GITIGNORE_PREVIEW } from '../src/harness.mjs'; +import { copyStaticAssets } from '../src/harness.mjs'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); test('.gitignore: .harness/를 통째로 무시하지 않고 개인 상태 파일만 무시한다', async () => { const dir = await mkdtemp(join(tmpdir(), 'harness-gitignore-')); try { - await copyStaticAssets({ root: ROOT, targetDir: dir, flags: {}, stackId: 'node', addAiGitignore: true }); + await copyStaticAssets({ root: ROOT, targetDir: dir, flags: {}, stackId: 'node' }); const lines = (await readFile(join(dir, '.gitignore'), 'utf8')).split('\n').map(l => l.trim()); - for (const wanted of ['.claude/settings.local.json', '.harness/active.json', '.harness/config.json', '.harness/observability/', 'docs/*/*-handoff.md']) { + for (const wanted of ['.claude/settings.local.json', '.harness/active.json', '.harness/config.json', '.harness/observability/', '.harness/backup/', 'docs/*/*-handoff.md']) { assert.ok(lines.includes(wanted), `${wanted} 는 무시돼야 한다`); } - assert.ok(!lines.includes('.harness/'), '.harness/ 전체를 무시하면 backup.json을 커밋할 수 없다'); - for (const gone of ['GEMINI.md', 'opencode.json', '.opencode', '.opencode/']) { - assert.ok(!lines.includes(gone), `${gone} 항목은 D7 이후 추가하지 않는다`); + assert.ok(!lines.includes('.harness/'), '.harness/ 전체를 무시하면 팀 상태(render-state.json)를 커밋할 수 없다'); + // AI 도구 항목 옵션은 제거됐다 — 하네스가 커밋 대상으로 삼는 에이전트 파일·설정은 무시하지 않는다. + for (const gone of ['CLAUDE.md', 'AGENTS.md', '.claude', '.claude/', '.codex', '.codex/', '.cursor', '.cursor/', '# AI']) { + assert.ok(!lines.includes(gone), `${gone} 항목은 추가하지 않는다`); } - assert.ok(lines.includes('.codex'), 'Codex 항목은 유지'); } finally { await rm(dir, { recursive: true, force: true }); } }); - -test('AI_GITIGNORE_PREVIEW(init이 보여주는 미리보기)는 실제 추가 목록과 같은 소스다', () => { - assert.doesNotMatch(AI_GITIGNORE_PREVIEW, /GEMINI|opencode/i); - assert.doesNotMatch(AI_GITIGNORE_PREVIEW, /^docs\/$/m, 'task SSOT가 있는 docs/는 무시 목록에 없다'); -}); diff --git a/tests/sim/codex-agentloop.mjs b/tests/sim/codex-agentloop.mjs index 8c3928d8..7ace885d 100644 --- a/tests/sim/codex-agentloop.mjs +++ b/tests/sim/codex-agentloop.mjs @@ -424,7 +424,7 @@ async function sc5Packaging() { sig('.codex-plugin/plugin.json parseable', Boolean(manifest.name)), sig('codex manifest points at ./skills/', manifest.skills === './skills/'), sig('harness-team skill shipped', await fileHas(join(ROOT, 'skills', 'harness-team', 'SKILL.md'), 'name: harness-team')), - sig('harness-codex-sim skill shipped', await fileHas(join(ROOT, 'skills', 'harness-codex-sim', 'SKILL.md'), 'name: harness-codex-sim')), + sig('harness-codex-sim skill shipped', await fileHas(join(ROOT, 'scripts', 'maintainer-skills', 'harness-codex-sim', 'SKILL.md'), 'name: harness-codex-sim')), ], }; } diff --git a/tests/symlink.test.mjs b/tests/symlink.test.mjs deleted file mode 100644 index 935a2218..00000000 --- a/tests/symlink.test.mjs +++ /dev/null @@ -1,87 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtemp, mkdir, writeFile, readFile, readlink, rm, lstat } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { runSymlink } from '../src/commands/symlink.mjs'; - -async function makeFixture() { - const parent = await mkdtemp(join(tmpdir(), 'harness-symlink-')); - const projectDir = join(parent, 'my-project'); - const backupDir = join(parent, 'harness-backup', 'my-project'); - - await mkdir(join(projectDir, '.harness'), { recursive: true }); - await writeFile( - join(projectDir, '.harness/backup.json'), - JSON.stringify({ dir: backupDir }), - ); - await mkdir(backupDir, { recursive: true }); - return { parent, projectDir, backupDir }; -} - -test('실파일이 backup과 다르면 --yes에도 절대 교체하지 않는다', async () => { - const { parent, projectDir, backupDir } = await makeFixture(); - try { - await writeFile(join(projectDir, 'CLAUDE.md'), 'PROJECT CONTENT'); - await writeFile(join(backupDir, 'CLAUDE.md'), 'BACKUP CONTENT'); - - await runSymlink({ targetDir: projectDir, flags: { yes: true } }); - - const st = await lstat(join(projectDir, 'CLAUDE.md')); - assert.equal(st.isSymbolicLink(), false, 'real file must NOT be replaced when contents differ'); - const content = await readFile(join(projectDir, 'CLAUDE.md'), 'utf8'); - assert.equal(content, 'PROJECT CONTENT'); - } finally { - await rm(parent, { recursive: true, force: true }); - } -}); - -test('실파일이 backup과 동일하면 안전하게 symlink로 교체', async () => { - const { parent, projectDir, backupDir } = await makeFixture(); - try { - await writeFile(join(projectDir, 'CLAUDE.md'), 'SAME'); - await writeFile(join(backupDir, 'CLAUDE.md'), 'SAME'); - - await runSymlink({ targetDir: projectDir, flags: { yes: true } }); - - const st = await lstat(join(projectDir, 'CLAUDE.md')); - assert.equal(st.isSymbolicLink(), true); - const target = await readlink(join(projectDir, 'CLAUDE.md')); - assert.ok(target.endsWith('CLAUDE.md')); - } finally { - await rm(parent, { recursive: true, force: true }); - } -}); - -test('실디렉토리 내용이 다르면 교체하지 않는다', async () => { - const { parent, projectDir, backupDir } = await makeFixture(); - try { - await mkdir(join(projectDir, '.claude'), { recursive: true }); - await writeFile(join(projectDir, '.claude/settings.json'), '{"a":1}'); - await mkdir(join(backupDir, '.claude'), { recursive: true }); - await writeFile(join(backupDir, '.claude/settings.json'), '{"a":2}'); - - await runSymlink({ targetDir: projectDir, flags: { yes: true } }); - - const st = await lstat(join(projectDir, '.claude')); - assert.equal(st.isSymbolicLink(), false); - const content = await readFile(join(projectDir, '.claude/settings.json'), 'utf8'); - assert.equal(content, '{"a":1}'); - } finally { - await rm(parent, { recursive: true, force: true }); - } -}); - -test('항목이 없으면 새 symlink 생성', async () => { - const { parent, projectDir, backupDir } = await makeFixture(); - try { - await writeFile(join(backupDir, 'CLAUDE.md'), 'BACKUP'); - - await runSymlink({ targetDir: projectDir, flags: { yes: true } }); - - const st = await lstat(join(projectDir, 'CLAUDE.md')); - assert.equal(st.isSymbolicLink(), true); - } finally { - await rm(parent, { recursive: true, force: true }); - } -}); diff --git a/tests/task-paths-single-source.test.mjs b/tests/task-paths-single-source.test.mjs index 535732b7..e29a2996 100644 --- a/tests/task-paths-single-source.test.mjs +++ b/tests/task-paths-single-source.test.mjs @@ -18,7 +18,7 @@ const ALLOWED_LINES = new Map([ ]); // 허용: migrate 의 동결된 레거시 구간 — 옛 구조를 서술하므로 헬퍼 모양과 달라야 한다. const LEGACY_START = '// --- Task structure migration (pre-0.6.0 → 0.6.0) ---'; -const LEGACY_END = '// --- Backup dir script migration'; +const LEGACY_END = '// --- Refresh installed .claude assets'; async function sourceFiles(dir) { const out = [];