diff --git a/.gitignore b/.gitignore index d63aefa5..32c01563 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ node_modules/ .claude/handoffs/ .superpowers/ tests/fixtures/**/actual/ +docs/*/*-handoff.md diff --git a/AGENTS.md b/AGENTS.md index d2c858f3..5c9f7e00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,7 +127,8 @@ SSOT는 여전히 네 파일이며, TCC는 거기서 파생된 현재 working se - **경계 계약**: spec의 `## Boundary contracts` JSON 선언이 있으면 plan checkbox 완료 직전에 `harness-team boundary check`가 생산자·소비자 JSON Schema의 필수 필드와 기본 type을 대조한다. 선언이 없으면 `boundary: not-configured`으로 통과한다. -- **commit 시**: post-commit hook이 handoff 2파일 갱신 — 다음 커밋에 담는다(예외: harness-task) +- **commit 시**: post-commit hook이 task handoff를 갱신 — 다음 커밋에 담는다(예외: harness-task). + `-handoff.md`도 함께 갱신되지만 워크트리 로컬(gitignore)이라 커밋하지 않는다 - **PR/MR 직전(ship)**: spec·plan·artifact를 최종 갱신하고 준비 완료를 보고한다 — 다이어그램 갱신은 옵트인이며, 절차·산출물 계약은 ship 명령 문서가 정본이다(PR/MR 생성은 별도 지시). - **완료**: plan 전체 완료 감지 또는 사용자 신호 → AskUserQuestion → `harness-team done` @@ -158,7 +159,7 @@ SSOT는 여전히 네 파일이며, TCC는 거기서 파생된 현재 working se | `CLAUDE.md` | `@AGENTS.md` import + Claude 전용 (워크플로우·서브에이전트·advisor) | | `docs///` | task별 작업 문서 4종 (spec·plan·handoff·artifact) | | `.harness/active.json` | 현재 활성 task 포인터 | -| `docs//-handoff.md` | 세션 시작 진입점 | +| `docs//-handoff.md` | 세션 시작 진입점 (워크트리 로컬·gitignore — task 활성화·커밋 때 갱신) | | `docs/decisions.md` | 팀 결정 로그 — 역할·프로토콜 규범의 근거·이력 정본 | | `.claude/rules/*.md` | 영역별 코딩 규칙 | diff --git a/CHANGELOG.md b/CHANGELOG.md index 83fd25e4..1742f7e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,16 @@ modified: 2026-09-13 ## [Unreleased] +### Fixed +- **같은 user의 병렬 PR이 머지마다 `docs//-handoff.md`에서 충돌하던 결함** (task `user-handoff-parallel-conflict`). + 이 파일의 내용은 전부 gitignore된 `.harness/active.json`과 그 워크트리의 `git log -1`에서 나오는 워크트리 로컬 렌더링인데 + 추적 파일이라, post-commit 훅이 커밋마다 재작성한 값이 브랜치끼리 반드시 부딪혔다(실측 3회). 이제 추적하지 않는다. + - 하네스 관리 `.gitignore`에 `docs/*/*-handoff.md`(깊이 2만 — task handoff는 계속 추적) 추가. 소비자 도달은 `init`·`migrate`. + - `task ` 활성화(생성·재활성)가 user handoff를 곧바로 쓴다 — 새 워크트리·clone에서 첫 커밋 전 진입점 공백 해소. + - 아직 추적 중인 저장소: `doctor`가 경고하고 `migrate`는 `.gitignore` 줄을 보장한 뒤 `git rm --cached` 명령을 **안내만** 한다(인덱스 무변경). + - "handoff 2파일을 다음 커밋에 담는다" 규범을 task handoff 1파일로 정정(AGENTS.md 관리 절은 `init --yes`로 도달). + - 전환기 1회: 아직 이 파일을 수정한 구 브랜치를 머지하면 modify/delete 충돌이 한 번 난다 — 삭제 쪽을 취한다. + ## [0.44.3] - 2026-09-27 ### Added diff --git a/README.md b/README.md index 012e5efb..2b5d91bd 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,7 @@ harness-team --help 스택 옵션: `react-native` | `react` | `next` | `node` | `python` | `go` | `generic` — 목록 밖의 값은 exit 2로 거부합니다. 감지와 다른 스택은 `.harness/render-state.json`에 고정되어 이후 플래그 없는 `init`·`doctor`도 그 스택을 쓰며, 감지된 id를 다시 주면 고정이 풀립니다. +고정 여부는 `harness-team stack`으로 확인합니다 — text는 `pin:` 줄, `--json`은 `stackPin`(고정 없으면 `null`) 필드에 감지·고정·유효 스택과 해제 명령이 나옵니다. React Native/Expo 전용 rules 4종은 유효 stack이 RN 계열일 때만 설치됩니다. ### `/harness-sync` — 내부 정합성 동기화 diff --git a/commands/harness-ship.md b/commands/harness-ship.md index 024769aa..9029eba7 100644 --- a/commands/harness-ship.md +++ b/commands/harness-ship.md @@ -52,7 +52,7 @@ Raw slash-command 인수: 5. **artifact 갱신** — `-artifact.md`에 결과·검증 증거(실제 테스트 출력)·남은 리스크· 후속 작업을 기록한다. 리뷰를 돌렸다면 `## Reviews`에 날짜와 함께 남긴다 — 기록 없는 리뷰는 - 안 한 것이다. 이 문서 갱신을 커밋할 때 트리에 남은 handoff 2파일 변경도 **같은 커밋에** 담는다 — + 안 한 것이다. 이 문서 갱신을 커밋할 때 트리에 남은 task handoff 변경도 **같은 커밋에** 담는다 — handoff만의 커밋을 따로 만들지 않는다(`commands/harness-task.md` post-commit handoff 절). 6. **다이어그램 (옵트인)** — `--no-diagram`이 없으면 `AskUserQuestion`으로 **한 번만** 묻는다: diff --git a/commands/harness-task.md b/commands/harness-task.md index 547eb762..f21c70ad 100644 --- a/commands/harness-task.md +++ b/commands/harness-task.md @@ -139,14 +139,21 @@ created: docs/// ## post-commit handoff — sweep 커밋에서는 침묵한다 `handoff`(post-commit 훅)는 커밋 **뒤에** `-handoff.md`(항목 append)와 `-handoff.md`(재작성)를 -쓴다. 그래서 커밋 직후 트리는 dirty다. +쓴다. 커밋 대상은 **task handoff 하나**다 — `-handoff.md`는 gitignore된 워크트리 로컬 파일이다 +(내용이 전부 `.harness/active.json`과 그 워크트리의 `git log -1`에서 나온다. 추적하던 시절엔 같은 user의 병렬 PR이 +머지마다 이 파일에서 충돌했다). 그래도 커밋 직후 트리는 task handoff 때문에 dirty다. **그 변경만 담는 커밋을 따로 만들지 않는다.** 다음 작업 커밋에 함께 stage한다(`git add` 대상에 -두 handoff 파일을 포함). 커밋마다 sweep을 붙이면 이력의 약 4분의 1이 "handoff 반영" 커밋이 된다 +task handoff를 포함). 커밋마다 sweep을 붙이면 이력의 약 4분의 1이 "handoff 반영" 커밋이 된다 (2026-09-20~25 실측: 98커밋 중 23개). dirty handoff는 `done` 가드가 무시하므로 남겨 둬도 종결을 막지 않는다. **예외** — 푸시·브랜치 전환·워크트리 정리 직전에 남은 변경이 handoff뿐이면 그때 단독 sweep 커밋 하나를 만든다. 보통 PR/MR당 한 번이지만 상한은 아니다 — 푸시 뒤 리뷰 대응 커밋이 생기면 다음 푸시 직전에 다시 한 번 쓴다. +`-handoff.md`는 `task ` 활성화 때도 한 번 쓴다 — 추적하지 않으므로 새 워크트리·clone에는 없고, +첫 커밋까지 세션 진입점이 비지 않게 하기 위해서다. 아직 이 파일을 **추적 중인** 저장소(gitignore 줄 도입 전)는 +무시 줄만으로는 충돌이 남는다 — `doctor`가 경고하고 `migrate`가 `.gitignore` 줄을 보장한 뒤 `git rm --cached` 명령을 +**안내만** 한다(인덱스는 건드리지 않는다). 기본 브랜치에서 한 번 실행해 커밋한다. + 단독 sweep 커밋이 **핸드오프 파일만** 바꿨다면 훅은 아무것도 쓰지 않는다 — 기록할 작업이 없고, 쓰면 churn이 자기 자신을 먹여 트리가 깨끗해지는 지점이 사라지기 때문이다(0.38.1). 판정은 보수적이다: diff --git a/docs/ao-worker-rules.md b/docs/ao-worker-rules.md index 4905030a..675418e4 100644 --- a/docs/ao-worker-rules.md +++ b/docs/ao-worker-rules.md @@ -24,8 +24,8 @@ gh api "repos/bd-makers/team-harness/check-runs//annotations" --jq '.[] | "\ `docs/task_summary.md` 와 `docs//-task.md` 는 **생성물**이고 기본 브랜치에서 `harness-team summary --write` 로만 갱신된다 — PR 브랜치에서 절대 건드리지 않는다. AO 는 워커마다 별도 워크트리를 띄우므로, PR 브랜치에서 수정하면 **여러 워커가 같은 줄을 동시에 고쳐 충돌한다.** task SSOT 4파일(`spec`·`plan`·`handoff`·`artifact`)은 task 디렉터리에 격리되어 있으니 자유롭게 수정해도 된다. -단, `docs//-handoff.md` 는 task 디렉터리 밖에 있고 post-commit 훅이 커밋마다 통째로 다시 쓴다 — 병렬 PR 끼리 반드시 -충돌한다. 충돌하면 손으로 병합하지 말고 기본 브랜치 쪽을 취한다(다음 커밋에서 훅이 다시 쓴다). +`docs//-handoff.md` 는 워크트리 로컬 파일이라 **추적하지 않는다**(gitignore) — 훅이 다시 써도 커밋·충돌 대상이 아니다. +`git add -f` 로 억지로 담지 마라. ## 3. 설치 단계는 없다 @@ -71,8 +71,8 @@ task 다이어그램은 **옵트인 단계**이지 상시 의무가 아니다. PR 단위 슬라이드 덱이며, 오케스트레이터가 지정한 상시 단계다. - **호출은 PR 이 생긴 뒤, 번호를 명시해서:** `/mr-change-diagram `. 스킬은 번호가 없으면 멈추고 되묻는다 — 현재 브랜치로 추측하게 두지 않는다. - **저장 경로: `docs/diagrams/pr/pr-<번호>-<슬러그>.html`.** 스킬은 폴더를 스스로 정하지 않으므로, 경로를 주지 않으면 되묻고 멈춘다. -- **이 산출물에 한해 커밋이 선승인되어 있다.** opt-in 을 다시 묻지 말고 덱 HTML 과, 트리에 남아 있는 handoff 2파일 - (`-handoff.md`·`-handoff.md`) 변경만 함께 스테이징한다 — 그 밖의 변경은 담지 않는다(`commands/harness-task.md` +- **이 산출물에 한해 커밋이 선승인되어 있다.** opt-in 을 다시 묻지 말고 덱 HTML 과, 트리에 남아 있는 task handoff + (`-handoff.md`) 변경만 함께 스테이징한다 — 그 밖의 변경은 담지 않는다(`commands/harness-task.md` post-commit handoff 절). PR 소스 브랜치에 푸시한다(`docs(diagram): PR <번호> 변경 슬라이드 추가`). `--amend`·`--force` 는 쓰지 않는다. - **건너뛰어도 되는 경우**: 문서·설정만 바뀌어 제품 동작 변화가 없는 PR. 근거를 한 줄 남긴다. - **스킬이 없는 머신**이면 실패로 처리하지 말고 건너뛰되, 보고에 `리뷰 덱 미실행(스킬 없음)` 이라고 남긴다. diff --git a/docs/chad/chad-handoff.md b/docs/chad/chad-handoff.md deleted file mode 100644 index 98c2aaf3..00000000 --- a/docs/chad/chad-handoff.md +++ /dev/null @@ -1,11 +0,0 @@ -# Session Handoff - -## Active Task -없음 — `.harness/active.json` 은 `null` 이다. -새 작업은 `harness-team task ` 으로 시작한다. - -## Last Completed Task (2026-09-24) -`task-area-flag` — done - -## Full Context -→ docs/chad/task-area-flag/task-area-flag-handoff.md diff --git a/docs/harness-overview.html b/docs/harness-overview.html index 73ddeaf1..b36fade3 100644 --- a/docs/harness-overview.html +++ b/docs/harness-overview.html @@ -2892,6 +2892,11 @@

병합 마커 포맷

Test 자동 회귀 검증 + + tests/user-handoff-untracked.test.mjs + Test + 자동 회귀 검증 + tests/user-handoff.test.mjs Test diff --git a/docs/hslee/hslee-handoff.md b/docs/hslee/hslee-handoff.md deleted file mode 100644 index 21eabf7b..00000000 --- a/docs/hslee/hslee-handoff.md +++ /dev/null @@ -1,11 +0,0 @@ -# Session Handoff - -## Active Task -없음 — `.harness/active.json` 은 `null` 이다. -새 작업은 `harness-team task ` 으로 시작한다. - -## Last Completed Task (2026-09-27) -`docs-version-drift-check` — done - -## Full Context -→ docs/hslee/docs-version-drift-check/docs-version-drift-check-handoff.md diff --git a/docs/hslee/user-handoff-parallel-conflict/user-handoff-parallel-conflict-artifact.md b/docs/hslee/user-handoff-parallel-conflict/user-handoff-parallel-conflict-artifact.md new file mode 100644 index 00000000..0eb6b6b0 --- /dev/null +++ b/docs/hslee/user-handoff-parallel-conflict/user-handoff-parallel-conflict-artifact.md @@ -0,0 +1,47 @@ +# user-handoff-parallel-conflict — Artifact + +*최종 결과물과 학습 내용을 기록한다.* + +## 결과 +**2026-09-27** — 선택지 A 구현(사용자 결정: A · 소비자 migrate는 안내만). +- `src/task-paths.mjs` `USER_HANDOFF_IGNORE` = `docs/*/*-handoff.md` → `src/harness.mjs` `appendGitignore`(export) 관리 목록에 추가. +- `src/commands/task.mjs`: `writeActivationHandoff`(생성·재활성 시 활성 형태 1회), `trackedUserHandoffs`(`git ls-files :(glob)…`). +- `src/commands/doctor.mjs` `checkTrackedUserHandoffs`(warn-only), `src/commands/migrate.mjs` `migrateUserHandoffUntrack`(gitignore 보장 + `git rm --cached` 안내, 인덱스 무변경). +- 이 레포 사본 `docs/chad/chad-handoff.md`·`docs/hslee/hslee-handoff.md` 추적 해제 + `.gitignore` 줄. +- 규범: AGENTS.md·템플릿·harness-task.md·harness-ship.md·ao-worker-rules §2·§7·templates/docs/README.md·docs/index.html. +- 검증: `tests/user-handoff-untracked.test.mjs` 7케이스(무시 줄을 빼면 4개 실패 — 병렬 머지 충돌 재현 확인), + `npm test` 1058 tests / 0 fail, `npm run docs:check` 최신. 기존 `tests/summary.test.mjs` 병렬 원장 테스트가 + 활성화 기록 때문에 실패 → fixture에 설치본과 같은 무시 줄 추가(전환 전 저장소는 종전과 같은 충돌 프로파일). +- 남은 리스크: 전환기 구 브랜치 머지 시 modify/delete 충돌 1회. 새 main을 pull한 워크트리에서 로컬 user handoff가 + 한 번 지워졌다가 다음 활성화·커밋에 재생성. + + +## Reviews +*Codex 등 리뷰 실행 시 결과(요약·발견·조치)를 날짜와 함께 남긴다. 남기지 않은 리뷰는 "안 한 것"으로 간주.* +*기계 판독용 마커를 함께 남긴다: ``* + +### 2026-09-27T14:33:44.091Z — codex (harness-team review) + +- engine: codex · scope: worktree · tip: fd37fa34b6ee510a077be456e2b3a297d02c1531 · exit 0 · 1288 B + +```text +전하, **P2 두 건이 있어 수정 후 진행을 권합니다.** 작업 트리와 staged 삭제를 모두 확인했으며 파일은 변경하지 않았습니다. + +- **P2 — [src/commands/migrate.mjs:1087](/Users/hsonpro/.ao/data/worktrees/harness-aijient-team-plugin/harness-aijient-team-plugin-6/src/commands/migrate.mjs:1087):** 추적 중인 handoff가 없으면 `.gitignore` 갱신도 건너뛰므로, 기존 설치에서 이후 생성한 user handoff가 다시 커밋될 수 있습니다. `doctor`도 이 경우 경고하지 않습니다. +- **P2 — [src/task-paths.mjs:28](/Users/hsonpro/.ao/data/worktrees/harness-aijient-team-plugin/harness-aijient-team-plugin-6/src/task-paths.mjs:28), [src/commands/task.mjs:712](/Users/hsonpro/.ao/data/worktrees/harness-aijient-team-plugin/harness-aijient-team-plugin-6/src/commands/task.mjs:712):** glob은 깊이 2만 제한하고 파일명이 실제 `-handoff.md`인지 확인하지 않습니다. 같은 위치의 다른 `*-handoff.md`도 무시되며 `git rm --cached` 안내 대상에 들어갑니다. + +**판정:** should-fix. 활성화 시점 기록과 task handoff를 제외하는 깊이 제한은 확인했습니다. `git diff --check`는 통과했으며, 읽기 전용 리뷰라 테스트는 실행하지 않았습니다. +``` + + + +**조치 (작성 세션 판별):** +- P2-1 **수용** — 추적 해제만 되고 무시 줄이 없으면 다음 `git add -A`가 재추적한다(재현 테스트로 확인). `migrate`가 추적 + 사본 유무와 무관하게 `.gitignore` 줄을 보장(`appendGitignore`가 변경 여부 반환), `doctor`가 `userHandoffIgnored`(probe + 경로 `git check-ignore`)로 무시 줄 누락도 경고. +- P2-2 **부분 수용** — gitignore 문법은 "파일명 = 디렉터리명-handoff.md"를 표현할 수 없어 같은 깊이의 다른 `*-handoff.md` + 신규 파일도 무시되는 것은 설계 한계로 남기고 `USER_HANDOFF_IGNORE` 주석에 명시(이미 추적 중인 파일은 영향 없음). + 추적 해제 **안내** 대상은 `/-handoff.md`만 남기도록 `trackedUserHandoffs`에 필터 추가. +- 회귀 테스트 2개 추가(9케이스), `npm test` 1060 tests / 0 fail, `docs:check` 최신. + +## Learnings diff --git a/docs/hslee/user-handoff-parallel-conflict/user-handoff-parallel-conflict-context.md b/docs/hslee/user-handoff-parallel-conflict/user-handoff-parallel-conflict-context.md new file mode 100644 index 00000000..04139a93 --- /dev/null +++ b/docs/hslee/user-handoff-parallel-conflict/user-handoff-parallel-conflict-context.md @@ -0,0 +1,25 @@ +# user-handoff-parallel-conflict — Context Card + + +## Now +- Goal: 병렬 PR 사이 `docs//-handoff.md` 충돌을 구조적으로 제거(진입점 기능 유지) +- Current atomic step: 구현·테스트 완료 → codex 리뷰 기록 → 커밋·push·PR·CI +- Stop / human-decision condition: 머지 금지(사람 지시로만). 0.44.3이 main에 먼저 들어가면 갱신(CHANGELOG 양쪽 유지). + +## Constraints and settled decisions +- 결정(09-27): 선택지 A, 소비자 migrate는 안내만(인덱스 무변경) +- 코드 읽기 소비자 0 — session-context는 active.json을 직접 읽는다(확인됨) +- `handoffRelPaths`는 변경하지 않는다(전환기 churn 방지) +- gitignore 패턴 `docs/*/*-handoff.md`는 깊이 2만 매치(임시 repo로 확인) + +## JIT retrieval map +- Identifiers / symbols: renderUserHandoff, runHandoffAuto, runDone, handoffRelPaths, appendGitignore, userHandoffRel +- Narrow globs: src/commands/task.mjs, src/harness.mjs, src/task-paths.mjs, tests/user-handoff.test.mjs, tests/handoff-hook-churn.test.mjs +- Read next: spec `## 설계 / 접근` A의 세부 설계 +- Verification command: npm test && npm run docs:check + +## Failure capsules (max 3 unresolved) +- (none) + +## Resume checklist +- 결정 내용을 spec Ambiguity 게이트에 반영 → plan 3단계 체크 → 실패 테스트부터 diff --git a/docs/hslee/user-handoff-parallel-conflict/user-handoff-parallel-conflict-handoff.md b/docs/hslee/user-handoff-parallel-conflict/user-handoff-parallel-conflict-handoff.md new file mode 100644 index 00000000..061faa44 --- /dev/null +++ b/docs/hslee/user-handoff-parallel-conflict/user-handoff-parallel-conflict-handoff.md @@ -0,0 +1,81 @@ +# user-handoff-parallel-conflict — Handoff + +(세션 종료 시 post-commit hook이 자동 갱신합니다) + +## 2026-09-27T13:46:14.031Z — fd37fa3 docs(task): user-handoff-parallel-conflict spec·plan — 병렬 PR의 user handoff 충돌 선택지와 권장안 +.../user-handoff-parallel-conflict-artifact.md | 13 ++ + .../user-handoff-parallel-conflict-context.md | 24 +++ + .../user-handoff-parallel-conflict-handoff.md | 3 + + .../user-handoff-parallel-conflict-meta.json | 11 ++ + .../user-handoff-parallel-conflict-plan.md | 29 ++++ + .../user-handoff-parallel-conflict-spec.md | 167 +++++++++++++++++++++ + 6 files changed, 247 insertions(+) + +## 2026-09-27T14:35:30.400Z — 97fd185 fix(handoff): user handoff를 워크트리 로컬 파일로 — 병렬 PR 충돌 제거 +.gitignore | 1 + + AGENTS.md | 5 +- + CHANGELOG.md | 8 + + commands/harness-ship.md | 2 +- + commands/harness-task.md | 11 +- + docs/ao-worker-rules.md | 8 +- + docs/chad/chad-handoff.md | 11 -- + docs/harness-overview.html | 5 + + docs/hslee/hslee-handoff.md | 11 -- + .../user-handoff-parallel-conflict-artifact.md | 34 ++++ + .../user-handoff-parallel-conflict-context.md | 5 +- + .../user-handoff-parallel-conflict-handoff.md | 9 + + .../user-handoff-parallel-conflict-meta.json | 12 +- + .../user-handoff-parallel-conflict-plan.md | 24 +-- + .../user-handoff-parallel-conflict-spec.md | 12 +- + docs/index.html | 2 - + src/commands/doctor.mjs | 23 ++- + src/commands/migrate.mjs | 22 ++- + src/commands/task.mjs | 44 ++++- + src/harness.mjs | 11 +- + src/task-paths.mjs | 7 + + templates/AGENTS.md.hbs | 5 +- + templates/docs/README.md | 2 +- + tests/gitignore-entries.test.mjs | 2 +- + tests/summary.test.mjs | 7 +- + tests/user-handoff-untracked.test.mjs | 209 +++++++++++++++++++++ + 26 files changed, 420 insertions(+), 72 deletions(-) + +## 2026-09-27T14:36:23.763Z — 593d31b Merge refs/remotes/origin/main (0.44.3) into user-handoff-parallel-conflict +.claude-plugin/marketplace.json | 2 +- + .claude-plugin/plugin.json | 2 +- + .codex-plugin/plugin.json | 2 +- + CHANGELOG.md | 16 + + MAINTAINING.md | 13 +- + commands/harness-init.md | 1 + + docs/ao-worker-rules.md | 2 +- + docs/followups.md | 13 +- + docs/harness-overview.html | 18 +- + docs/harness-overview.template.html | 8 +- + .../docs-version-drift-check-artifact.md | 33 +++ + .../docs-version-drift-check-context.md | 27 ++ + .../docs-version-drift-check-handoff.md | 32 ++ + .../docs-version-drift-check-meta.json | 21 ++ + .../docs-version-drift-check-plan.md | 20 ++ + .../docs-version-drift-check-spec.md | 42 +++ + docs/hslee/hslee-task.md | 2 + + .../stack-pin-display-artifact.md | 30 ++ + .../stack-pin-display/stack-pin-display-context.md | 27 ++ + .../stack-pin-display/stack-pin-display-handoff.md | 30 ++ + .../stack-pin-display/stack-pin-display-meta.json | 21 ++ + .../stack-pin-display/stack-pin-display-plan.md | 21 ++ + .../stack-pin-display/stack-pin-display-spec.md | 52 ++++ + docs/index.html | 1 + + docs/task_summary.md | 2 + + docs/what-changes-0.44.3.html | 322 +++++++++++++++++++++ + docs/what-changes-latest-version.html | 76 +++-- + package.json | 2 +- + scripts/docs-version-drift.mjs | 102 +++++++ + scripts/generate-harness-overview.mjs | 10 + + src/commands/stack.mjs | 22 +- + tests/detect-testing.test.mjs | 23 ++ + tests/docs-version-drift.test.mjs | 93 ++++++ + 33 files changed, 1040 insertions(+), 48 deletions(-) + +## 2026-09-27T14:43:10.068Z — e0f0810 docs(readme): init --stack 고정 여부를 harness-team stack으로 확인한다 +README.md | 1 + + 1 file changed, 1 insertion(+) diff --git a/docs/hslee/user-handoff-parallel-conflict/user-handoff-parallel-conflict-meta.json b/docs/hslee/user-handoff-parallel-conflict/user-handoff-parallel-conflict-meta.json new file mode 100644 index 00000000..2b9e9314 --- /dev/null +++ b/docs/hslee/user-handoff-parallel-conflict/user-handoff-parallel-conflict-meta.json @@ -0,0 +1,21 @@ +{ + "user": "hslee", + "task": "user-handoff-parallel-conflict", + "created": "2026-09-27", + "firstActivatedAt": "2026-09-27T13:43:11.726Z", + "status": "open", + "closedAt": null, + "forcedAt": null, + "forcedIssues": null, + "reviews": [ + { + "kind": "codex", + "engine": "codex", + "scope": "worktree", + "tip": "fd37fa34b6ee510a077be456e2b3a297d02c1531", + "at": "2026-09-27T14:33:44.091Z", + "exitCode": 0, + "outputBytes": 1288 + } + ] +} diff --git a/docs/hslee/user-handoff-parallel-conflict/user-handoff-parallel-conflict-plan.md b/docs/hslee/user-handoff-parallel-conflict/user-handoff-parallel-conflict-plan.md new file mode 100644 index 00000000..cd6cd35b --- /dev/null +++ b/docs/hslee/user-handoff-parallel-conflict/user-handoff-parallel-conflict-plan.md @@ -0,0 +1,29 @@ +# user-handoff-parallel-conflict — Plan + +## 목표 +병렬 PR(D5)끼리 `docs//-handoff.md` 때문에 충돌하는 일을 구조적으로 없앤다. +선택지 A(같은 경로 유지·추적 해제, 워크트리 로컬 상태로 강등) — 2026-09-27 사용자 결정. + +## 단계 +- [x] 소비자 전수 조사·충돌 실측 확인(`3323c6f`·`fdcffbc`·`a7c8354`) → spec 기록 +- [x] 선택지 A~D 정리·권장안(A) 선정 → spec 기록 +- [x] 오케스트레이터 결정 수령(A · migrate 안내만) — spec Ambiguity 게이트 통과 처리 +- [x] 실패 테스트 먼저(`tests/user-handoff-untracked.test.mjs` 7케이스 — done 가드·sweep 무시 파일 포함): 같은 user 두 브랜치가 각자 커밋 후 머지해도 user handoff 충돌이 없음(e2e) · gitignore 목록에 `docs/*/*-handoff.md` 포함 · `task ` 활성화가 user handoff를 쓴다 +- [x] `src/harness.mjs` `appendGitignore` `harnessNeeded`에 `DOCS_DIR` 기반 패턴 추가 +- [x] `src/commands/task.mjs` task 활성화 경로에서 `renderUserHandoff` 활성 형태 1회 기록 +- [x] `migrate`: 추적 중인 user handoff 감지 → `.gitignore` 줄 보장 + `git rm --cached` 안내(인덱스 무변경) +- [x] `doctor`: 추적 중인 user handoff 경고 1줄 +- [x] 규범 문구 갱신: AGENTS.md·`templates/AGENTS.md.hbs`·`templates/docs/README.md`·`commands/harness-task.md`·`commands/harness-ship.md`·`docs/ao-worker-rules.md` §2·§7·`docs/index.html` 링크 +- [x] `npm test`·`npm run docs:check` 통과(golden fixture는 변경 불필요, `tests/summary.test.mjs` fixture에 무시 줄 추가) +- [x] CHANGELOG `## [Unreleased]` 항목 추가 +- [x] 리뷰(read-only 외부 검증) → artifact `## Reviews` 기록 — codex P2 2건 조치 +- [ ] 커밋·PR — 이 레포 사본 추적 해제(`git rm --cached docs/chad/chad-handoff.md docs/hslee/hslee-handoff.md`)는 결정 [1]대로 이 PR에 포함 + +## Ontology 변경 로그 +*개념이 새로 정의되거나 의미가 바뀌면 한 줄로 기록. spec.md의 Ontology 섹션을 갱신할 트리거가 된다.* + +- 2026-09-27: user handoff = 추적되는 세션 진입점 → (A 채택 시) 워크트리 로컬 렌더링. 정본은 `.harness/active.json`. + +## 참고 +- 설계·선택지·소비자 조사: `user-handoff-parallel-conflict-spec.md` +- 다이어그램 단계 없음 — AO 워커는 사용자에게 옵트인을 물을 수 없어(ao-worker-rules §5) 추가하지 않았다. diff --git a/docs/hslee/user-handoff-parallel-conflict/user-handoff-parallel-conflict-spec.md b/docs/hslee/user-handoff-parallel-conflict/user-handoff-parallel-conflict-spec.md new file mode 100644 index 00000000..33d5267f --- /dev/null +++ b/docs/hslee/user-handoff-parallel-conflict/user-handoff-parallel-conflict-spec.md @@ -0,0 +1,167 @@ +# user-handoff-parallel-conflict — Spec + +## 목적 / 요구사항 + +**문제(사실).** post-commit 훅(`runHandoffAuto`, `src/commands/task.mjs`)은 커밋마다 task 디렉터리 **밖**의 +`docs//-handoff.md`를 `renderUserHandoff`로 **통째로 재작성**한다(Active Task·Last Commit 줄에 커밋 +sha·메시지가 들어가 커밋마다 바이트가 바뀐다). #105 이후 이 변경은 "다음 작업 커밋에 함께 stage"된다 +(`commands/harness-task.md` post-commit handoff 절). 따라서 같은 user로 D5 병렬 PR 두 개가 열리면 두 브랜치 모두 +같은 파일의 같은 줄을 반드시 바꾸고, 먼저 머지된 쪽 뒤로 나머지는 **항상** 충돌한다. + +**실측(08-26 이후 3회, 병합 커밋 메시지로 확인).** +- `3323c6f` (08-26) `Conflicts: docs/chad/chad-handoff.md` — AO 워커 브랜치에 main 병합 +- `fdcffbc` (08-30) `Conflicts: docs/hslee/hslee-handoff.md` — 기능 브랜치에 main 병합 +- `a7c8354` (09-08) `chad-handoff.md` 충돌을 main 쪽으로 해소 — 메시지가 이미 근본 원인을 짚는다: + "이 파일은 post-commit hook이 생성하는 렌더링 … 활성 task 판정의 정본은 gitignore된 `.harness/active.json`" + +**현재 수습.** `docs/ao-worker-rules.md` §2(#112)가 "충돌하면 기본 브랜치 쪽을 취한다"로 운용만 한다. +충돌 자체는 매 병렬 PR마다 남고, AO처럼 워커가 여럿이면 선형으로 늘어난다. + +**근본 원인(추론, 아래 근거로 확정).** 이 파일의 내용은 전부 **워크트리 로컬 상태의 파생**이다. +- `Active Task` ← `.harness/active.json`(gitignore, 워크트리마다 다름) +- `Last Commit` ← 그 워크트리의 `git log -1` +- `Full Context` ← task 경로(결정론적) + +즉 "브랜치마다 달라야 정상인 값"을 "브랜치 사이에 공유되는 추적 파일"에 쓰고 있다. 머지하면 어느 쪽을 취하든 +**다른 워크트리의 사실**이 남는다 — 충돌 해소를 아무리 잘해도 커밋된 값은 머지 직후 이미 틀리다 +(현재 main의 `hslee-handoff.md`도 이 워크트리의 활성 task와 무관한 값을 가리킨다). + +**영향받는 사용자·시스템.** 같은 user로 병렬 브랜치·워크트리를 쓰는 모든 경로(AO 워커, `claude/*` 브랜치, 소비자 +프로젝트의 팀원). 소비자 프로젝트도 이 파일을 커밋하므로 플러그인 사용자 전체가 대상이다. + +**기대 결과.** 병렬 PR끼리 user handoff 때문에 충돌하는 일이 **구조적으로 0**이 된다. 세션 진입점 기능 +("지금 활성 task가 무엇이고 어디서 이어 읽는가")은 유지된다. + +**제약.** +- 런타임 의존성 0, Node ≥24(레포 규칙). 새 의존성 없음. +- 기존 소비자 프로젝트의 커밋된 파일을 **깨뜨리지 않는** 마이그레이션 경로가 있어야 한다(D8 — 전면 덮어쓰기 금지 정신). +- `handoffRelPaths` 단일 정의(훅 제외 집합 = `done` 가드 무시 집합)는 유지한다. +- 버전 범프·릴리스는 범위 밖(ao-worker-rules §5). + +### 소비자 전수 조사 (2026-09-27, 이 워크트리 `c87b50d` 기준) + +**쓰기 경로 (코드)** +| 위치 | 동작 | +|---|---| +| `src/commands/task.mjs` `runHandoffAuto` | 활성 task가 있으면 커밋마다 활성 형태로 재작성(sweep 커밋은 침묵) | +| `src/commands/task.mjs` `runDone` | 종결 시 1회 종결 형태로 재작성 | +| `src/commands/task.mjs` `renderUserHandoff` | 유일 렌더러(두 형태) | +| `src/task-paths.mjs` `userHandoffRel`·`userHandoffPath` | 경로 정의 | +| `src/commands/task.mjs` `handoffRelPaths` | 훅의 "handoff만 바꾼 커밋" 판정 + `done` 가드 무시 집합 | + +**읽기 경로 (코드)** — **없다.** `userHandoffPath`/`userHandoffRel`을 읽는 코드는 테스트뿐이다. +- `session-context`(SessionStart task-gate, Claude·Codex 공통 — D9)는 이 파일이 아니라 `.harness/active.json` + (`readActive`)과 TCC를 직접 읽는다. 즉 **기계 진입점은 이미 이 파일에 의존하지 않는다.** +- `summary`·`doctor`·`remote-task`·`list`는 task handoff(`-handoff.md`)나 meta만 본다. + +**읽기 경로 (규범·문서 — 사람/에이전트가 읽음)** +- `AGENTS.md`·`templates/AGENTS.md.hbs` "세션 시작 시 1. `docs//-handoff.md` 읽기", 컨텍스트 파일 표, + "commit 시: post-commit hook이 handoff 2파일 갱신 — 다음 커밋에 담는다" +- `templates/docs/README.md` 디렉터리 트리 설명 +- `commands/harness-task.md` post-commit handoff 절, `commands/harness-ship.md` §55(handoff 2파일 동봉) +- `docs/ao-worker-rules.md` §2(충돌 수습)·§7(리뷰 덱 커밋에 handoff 2파일 동봉) +- `docs/index.html` 링크(`chad/chad-handoff.md`, `hslee/hslee-handoff.md`) +- 과거 HTML 스냅샷(`docs/harness-*-0.x.html`)·`CHANGELOG.md` — 이력이므로 갱신 대상 아님 +- 외부: `~/.claude` 개인 스킬(session-handoff 등)은 이 파일을 읽지 않는다(별도 경로 `.claude/handoffs/`). + +**테스트** +- `tests/user-handoff.test.mjs`(12케이스, 렌더러·종결 형태·done 연동) +- `tests/handoff-hook-churn.test.mjs`(`handoffRelPaths`가 두 경로를 돌려준다·sweep 침묵) +- `tests/done-guard.test.mjs`(rename 제외 판정에 user handoff 경로 사용) +- `tests/fixtures/task-paths-golden/expected.txt`(`handoff updated: docs/tester/tester-handoff.md` 출력·파일 본문) + +## 설계 / 접근 + +### 선택지 + +**A. 로컬 상태로 강등 — 같은 경로를 유지하되 추적 해제(gitignore) (권장)** +훅·`done`·렌더러는 그대로 두고, `docs/*/*-handoff.md`(깊이 2 한정 — task handoff `docs/u/t/t-handoff.md`는 +매치하지 않는다)를 하네스 관리 gitignore 목록(`appendGitignore`의 `harnessNeeded`)에 추가하고, 이미 추적 중인 +사본은 1회 `git rm --cached`로 인덱스에서 뺀다. 파일은 정본(`active.json`)과 같은 수명·범위(워크트리 로컬)를 갖게 된다. +- 호환성: 경로·형식 불변 → "세션 시작 시 이 파일을 읽어라" 규범이 그대로 동작. 달라지는 것은 "커밋에 담는다"뿐. +- 소비자 영향: 코드 읽기 소비자 0. 새 워크트리·새 clone에선 첫 커밋 전까지 파일이 없다 — 대신 `task ` + 활성화 시점에 활성 형태를 1회 쓰면 공백이 사라진다(오늘도 새 워크트리의 커밋된 사본은 **다른 브랜치의 값**이라 틀리다 — + 없는 편이 틀린 것보다 낫다). +- 잃는 것: 다른 머신·팀원이 git으로 보던 "내 마지막 종결 task" — 이는 생성물 `-task.md`(summary)와 + task meta가 이미 정본으로 담는다. +- 마이그레이션: ① gitignore 줄 추가(init·migrate가 이미 쓰는 경로) ② 추적 해제 1회 — 이 레포는 기본 브랜치에서 + 커밋 1개, 소비자는 `migrate`가 안내(또는 수행 — 결정 필요, 아래 open). 전환기 1회 부작용: 아직 파일을 수정한 + 구 브랜치를 머지하면 modify/delete 충돌 1회, 새 main을 pull한 워크트리에선 로컬 파일이 한 번 지워졌다가 다음 커밋에 재생성. +- 테스트 부담: 작음 — gitignore 목록 테스트 1, `task` 활성화 시 기록 1, golden fixture 갱신, 기존 12케이스는 불변. + +**B. 기본 브랜치 전용 생성물 — summary처럼 기본 브랜치에서만 갱신** +훅이 기본 브랜치가 아니면 user handoff를 쓰지 않는다(`default-branch-primitive` 재사용). 추적은 유지. +- 호환성: 파일은 계속 커밋된다. 하지만 기능 브랜치에서 작업 중인 세션의 진입점이 **main의 낡은 값**을 가리킨다 — + 진입점의 존재 이유(현재 활성 task)가 병렬 작업 중에 정확히 깨진다. +- 기본 브랜치 판정이 모호한 환경(origin 없음·detached·AO `ao/*/root`)에서 판정 불가 처리 규칙이 추가로 필요. +- 병렬 세션이 기본 브랜치에서 동시에 커밋하면 여전히 충돌(드묾). +- 테스트 부담: 중간(브랜치 판정 분기·판정 불가 경로). +- 기각 근거: 충돌은 줄이지만 **내용이 틀린 상태**를 공식화한다. + +**C. task 디렉터리로 분할 — user 레벨 파일 폐지, 포인터를 task별 파일로** +`docs///`에 이미 `-handoff.md`가 있으므로 user 레벨 포인터를 없애고 "활성 task의 handoff를 읽어라"로 +규범을 바꾼다. 병렬 PR은 서로 다른 task 디렉터리를 쓰므로 충돌이 없다. +- 호환성: 규범 변경(AGENTS.md·템플릿·README), 파일 삭제 → 기존 소비자 저장소의 커밋된 사본이 고아가 된다. +- "지금 활성 task가 무엇인가"는 active.json·session-context로만 알 수 있다 — 사실상 D와 같아지고, 분할 자체는 + 얻는 것이 없다(task handoff는 이미 분할되어 있다). +- 테스트 부담: 큼(user-handoff 12케이스 재작성·golden). + +**D. 파생 뷰로 대체 — 파일 폐지, `harness-team session-context`(또는 `handoff --show`)가 진입점** +기계 진입점은 이미 session-context다(Claude·Codex SessionStart). 파일을 없애고 규범 1단계를 "session-context 출력(자동 주입)"으로 바꾼다. +- 가장 깔끔한 개념(정본 1개, 파생물 0개). 그러나 파일을 직접 열어 보는 사람·Cursor(훅 없음, 규범 의존) 경로가 끊기고, + AGENTS.md 코어 규범·템플릿·README·렌더러·`done`·테스트를 한꺼번에 바꾸는 큰 변경이다(5-A 게이트 해당). +- 테스트 부담: 큼. + +### 권장: A + +한 줄 근거: 충돌의 원인은 "워크트리 로컬 파생값을 추적한다"는 것 하나이고, A는 **그것만** 고친다 — 경로·형식·렌더러· +읽는 규범이 모두 그대로라 변경 면적이 가장 작고, 정본(`active.json`)과 수명이 같아져 머지 후 값이 틀리는 문제도 함께 사라진다. +D는 개념적으로 더 깨끗하지만 규범·Cursor 경로를 깨므로, A 적용 후 필요가 드러나면 후속으로 검토한다. + +### A의 세부 설계 (결정 후 구현) +1. `appendGitignore`의 `harnessNeeded`에 `docs/*/*-handoff.md` 추가. `DOCS_DIR` 상수에서 조립한다(하드코딩 복제 금지). +2. `task ` 활성화(생성·재활성) 시 활성 형태를 1회 쓴다(`commitMsg` 빈 값 허용 — 렌더러 기존 인자). 새 워크트리 공백 해소. +3. 추적 해제: 이 레포는 기본 브랜치에서 `git rm --cached docs/chad/chad-handoff.md docs/hslee/hslee-handoff.md` 커밋 1개. + 소비자는 `migrate`가 gitignore 줄을 보장하고(파일 편집만), 추적 중인 user handoff가 있으면 `git rm --cached` 명령을 **안내만** 한다. +4. `doctor`: 추적 중인 user handoff가 남아 있으면 경고 1줄(전환 누락 탐지). +5. 규범 문구: AGENTS.md(·템플릿) "commit 시 handoff 2파일" → task handoff 1파일 + user handoff는 로컬, harness-task.md + post-commit 절, harness-ship.md·ao-worker-rules §2·§7의 "2파일" 문구, templates/docs/README.md 주석, docs/index.html 링크 제거. +6. `handoffRelPaths`는 **변경하지 않는다** — 무시 목록에 ignored 경로가 있어도 무해하고, 전환기(아직 추적 중인 저장소)에서 + churn 방지가 계속 필요하다. + +## Ontology +*이 task가 다루는 핵심 개념의 정의. "X가 정확히 뭔가?"에 답한다.* + +- **user handoff**: `docs//-handoff.md`. 활성 task 포인터·마지막 커밋·task handoff 경로를 담은 **렌더링**. + 정본이 아니다 — 정본은 `.harness/active.json`(활성), task meta(종결), task handoff(커밋 이력). +- **task handoff**: `docs///-handoff.md`. task별 커밋 로그(append). task 디렉터리에 격리되어 병렬 충돌 없음. +- **워크트리 로컬 상태**: 워크트리·clone마다 달라야 정상인 값. gitignore 대상(`active.json`, `config.json`, 그리고 A 채택 시 user handoff). +- **생성물(기본 브랜치 전용)**: 공유되지만 기본 브랜치에서만 갱신하는 추적 파일(`task_summary.md`, `-task.md`). 선택지 B가 user handoff를 여기 넣는 안. +- **게이트 근거**: 문제·원인·소비자·선택지가 코드·커밋으로 확정됐고, 남은 두 결정(선택지 A, migrate 안내만)을 2026-09-27 사용자가 내렸다. + +## Ambiguity 자가진단 +*각 항목이 명확하면 체크. 3개 이상 미체크면 구현 진입 금지 — 인터뷰/브레인스토밍으로 복귀해 +모호성을 제거한다. 게이트를 통과하면 그 근거를 위 Ontology 섹션에 한 줄로 남긴다.* + +- [x] **Goal 명확도** (40%) — 병렬 PR 사이 user handoff 충돌을 구조적으로 0으로, 진입점 기능은 유지. +- [x] **Constraint 명확도** (30%) — 2026-09-27 사용자 결정: 선택지 A, 소비자 저장소는 migrate가 인덱스를 건드리지 않고 안내 + doctor 경고. +- [x] **Success 기준** (30%) — 같은 user의 두 브랜치가 각자 커밋 후 머지해도 user handoff 충돌이 없다(e2e 재현 테스트), `npm test`·`docs:check` 통과. +- [x] **Context 명확도** (brownfield 한정) — 쓰기 5곳·읽기 코드 0곳·규범 문서·테스트 4파일 식별(위 전수 조사). +- [x] **Ambiguity ≤ 0.2** — 가중합 1.0. 게이트 통과(2026-09-27). + + +## Done evidence + + +## 참고 +- 정본: `commands/harness-task.md` post-commit handoff 절 · `src/commands/task.mjs` `renderUserHandoff`·`runHandoffAuto`·`runDone`·`handoffRelPaths` · `src/harness.mjs` `appendGitignore` · `AGENTS.md` D4·D5 · `docs/decisions.md` D5·D8 +- 충돌 실측: `3323c6f`·`fdcffbc`·`a7c8354` (병합 메시지의 `Conflicts:`) +- 선행 task: `docs/chad/done-user-handoff-freeze`(종결 형태 도입), `docs/chad/handoff-hook-churn`(sweep 침묵), `docs/hslee/handoff-sweep-fold`(#105 다음 커밋 동봉), `docs/chad/d5-parallel-pr-scope` +- (결정 2026-09-27) 선택지 **A** 채택. 소비자 저장소 추적 해제는 `migrate`가 **안내만**(인덱스 무변경) + `doctor` 경고. +- (결정 2026-09-27, 오케스트레이터 범위 지시) #105 계열 "handoff 2파일을 다음 커밋에 담는다" 문구를 1파일(task handoff)로 정정 — harness-task.md·AGENTS.md(+templates, 소비자 도달은 `init --yes`)·ao-worker-rules §2·§7. 테스트: 새 워크트리 공백 해소, done 가드·sweep 판정이 무시 파일을 올바로 다룸. diff --git a/docs/index.html b/docs/index.html index 9990c55e..90a91602 100644 --- a/docs/index.html +++ b/docs/index.html @@ -321,9 +321,7 @@

Task records

Individual task detail (spec / plan / handoff / artifact) lives under <user>/<task-name>/ — see task_summary.md for the full list. diff --git a/src/commands/doctor.mjs b/src/commands/doctor.mjs index 102163b8..9853307c 100644 --- a/src/commands/doctor.mjs +++ b/src/commands/doctor.mjs @@ -12,8 +12,8 @@ import { mergeMarkdown, extractSections } from '../merge.mjs'; import { buildEnvelope, buildErrorPacket, emitObservation } from '../observation.mjs'; import { settingsHasSessionGate } from './session-context.mjs'; import { checkDoneOnMain } from './remote-task.mjs'; -import { readActive } from './task.mjs'; -import { taskLabel, taskFilePath } from '../task-paths.mjs'; +import { readActive, trackedUserHandoffs, userHandoffIgnored } from './task.mjs'; +import { taskLabel, taskFilePath, USER_HANDOFF_IGNORE } from '../task-paths.mjs'; import { checkRuleProvenance } from './rules.mjs'; import { findStaleTemplates, isKnownStockTemplate } from './migrate.mjs'; import { evaluateObserveVerdict, observeLoopbackNudge, tripWireDetail } from './observe.mjs'; @@ -496,6 +496,21 @@ export async function checkDecisionLog(targetDir, root) { return `${DECISION_LOG_PATH}에 ${missing.join(', ')} 절 없음 — 팀 결정 로그는 설치 후 팀이 저작하는 파일이라 init·migrate 어느 쪽도 덮어쓰지 않는다(D8: docs/ seed는 refresh 비목표). \`${source}\` 에서 해당 절을 복사해 ${DECISION_LOG_PATH} 끝에 덧붙여라 — 이미 있는 절은 건드리지 말 것`; } +// user handoff 는 추적하지 않는 워크트리 로컬 파일이다(USER_HANDOFF_IGNORE). 두 전환 누락을 잡는다 — +// ① 아직 추적 중(무시 줄만으로는 병렬 PR 충돌이 남는다) ② 무시 줄이 없음(다음 `git add -A` 가 다시 추적한다). +// 인덱스 변경은 사용자 커밋 경계라 도구가 하지 않는다 — 명령만 알려 준다. warn-only(fail 에 세지 않는다). +export async function checkTrackedUserHandoffs(targetDir) { + const tracked = await trackedUserHandoffs(targetDir); + if (tracked.length) { + return `user handoff 가 아직 git 에 추적됨(병렬 PR 충돌 원인): ${tracked.join(', ')} — ` + + `기본 브랜치에서 \`git rm --cached ${tracked.join(' ')}\` 후 커밋 (.gitignore 줄은 \`harness-team migrate\`가 보장)`; + } + if ((await userHandoffIgnored(targetDir)) === false) { + return `.gitignore 에 \`${USER_HANDOFF_IGNORE}\` 줄이 없어 user handoff 가 다시 커밋될 수 있음 — \`harness-team migrate\` 로 추가`; + } + return null; +} + // observe-surfacing: the trip-wire verdict reaches doctor as ONE warn-level line built from the // same evaluateObserveVerdict the observe CLI uses, so the two cannot disagree. Silent unless // tripped — not-installed (no hook log; this plugin-dev repo, by D7) / no-data / ok all return @@ -935,6 +950,10 @@ export async function runDoctor(ctx) { const decisionLogWarning = await checkDecisionLog(ctx.targetDir, ctx.root); if (decisionLogWarning) add('decision log', 'warning', decisionLogWarning, `\n⚠️ ${decisionLogWarning}`); + // Not gated on pluginDev: this repo tracked its own user handoffs too. + const trackedHandoffWarning = await checkTrackedUserHandoffs(ctx.targetDir); + if (trackedHandoffWarning) add('tracked user handoff', 'warning', trackedHandoffWarning, `\n⚠️ ${trackedHandoffWarning}`); + // Not gated on pluginDev: a repo without the hook's log is `not-installed`, which is // silent on its own (observe-surfacing spec, 설계 절). const observeWarning = await checkObserveTripWires(ctx.targetDir); diff --git a/src/commands/migrate.mjs b/src/commands/migrate.mjs index 161f4f20..e8f03a89 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 { loadBackupDir, mergeClaudeSettings, settingsHasBoundaryCheckpoint, mirrorCursorRules, AGENT_FILE_TEMPLATES, isLegacyCodexSessionCommand, withCodexHookFlag } from '../harness.mjs'; +import { appendGitignore, loadBackupDir, 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'; @@ -12,7 +12,7 @@ import { confirm } from '../prompt.mjs'; import { installPostCommitHook } from '../git-hooks.mjs'; import { taskArtifactTemplate, parseReviewMarkers, evidenceWindowStart, isVerifyKind, - parseDoneEvidenceDeclaration, VERIFY_KIND_SUFFIXES, + parseDoneEvidenceDeclaration, VERIFY_KIND_SUFFIXES, trackedUserHandoffs, } from './task.mjs'; import { collectTasks, readTaskMeta, writeTaskMeta, metaRel } from './summary.mjs'; import { settingsHasSessionGate } from './session-context.mjs'; @@ -1080,6 +1080,21 @@ export async function adoptTaskReviews(ctx) { return true; } +// user handoff 추적 해제(USER_HANDOFF_IGNORE). .gitignore 줄은 **항상** 보장하지만(파일 편집) 인덱스는 건드리지 않는다 — +// `git rm --cached` 는 사용자의 커밋 경계와 겹치므로 명령만 안내한다(2026-09-27 결정). 추적 중인 사본이 없어도 줄은 넣는다 — +// 추적 해제만 된 저장소에서 다음 `git add -A` 가 파일을 다시 추적하기 때문이다(codex P2). 둘 다 할 일이 없으면 false. +export async function migrateUserHandoffUntrack(ctx) { + const ignoreAdded = await appendGitignore(ctx.targetDir); + if (ignoreAdded) console.log(' ✓ .gitignore: 하네스 관리 무시 줄 보강 (user handoff 는 워크트리 로컬 파일)'); + const tracked = await trackedUserHandoffs(ctx.targetDir); + if (tracked.length) { + console.log(` ⚠ user handoff 가 아직 git 에 추적됨: ${tracked.join(', ')}`); + console.log(' 워크트리 로컬 파일이라 추적하면 병렬 PR 끼리 충돌한다. 인덱스는 건드리지 않았다.'); + console.log(` 기본 브랜치에서 실행 후 커밋: git rm --cached ${tracked.join(' ')}`); + } + return ignoreAdded || tracked.length > 0; +} + export async function runMigrate(ctx) { console.log(`harness-team migrate → ${ctx.targetDir}`); @@ -1099,13 +1114,14 @@ export async function runMigrate(ctx) { const metaBackfilled = await backfillTaskMeta(ctx); // backfill 뒤에 둔다 — 방금 만들어진 meta 도 같은 실행에서 후보가 되게 한다. const reviewsAdopted = await adoptTaskReviews(ctx); + const userHandoffUntrack = await migrateUserHandoffUntrack(ctx); if (boundaryHookMigrated === null) { console.log('\nMigration incomplete — resolve the PreToolUse boundary checkpoint issue and rerun.'); return; } - if (!managedBackedUp && !agentsMigrated && !taskMigrated && !taskUpgraded && !scriptMoved && !scriptRefreshed && !claudeHooksRefreshed && !claudeTemplatesRefreshed && !taskLabelsRenamed && !hookMigrated && !boundaryHookMigrated && !metaBackfilled && !reviewsAdopted && !codexFlagMigrated) { + if (!managedBackedUp && !agentsMigrated && !taskMigrated && !taskUpgraded && !scriptMoved && !scriptRefreshed && !claudeHooksRefreshed && !claudeTemplatesRefreshed && !taskLabelsRenamed && !hookMigrated && !boundaryHookMigrated && !metaBackfilled && !reviewsAdopted && !codexFlagMigrated && !userHandoffUntrack) { console.log('\nNothing to migrate — project is already up to date.'); return; } diff --git a/src/commands/task.mjs b/src/commands/task.mjs index bd8e1605..923093f2 100644 --- a/src/commands/task.mjs +++ b/src/commands/task.mjs @@ -11,7 +11,7 @@ import { checkDoneOnMain, renderDoneOnMainNudge, listBranchOnlyTasks } from './r import { findCommand } from '../cli-args.mjs'; import { userNameError } from '../user-config.mjs'; import { - taskDirRel, taskFileRel, userHandoffRel, taskLabel, docsPath, taskDirPath, taskFilePath, userHandoffPath, listTaskRefs, + taskDirRel, taskFileRel, userHandoffRel, USER_HANDOFF_IGNORE, taskLabel, docsPath, taskDirPath, taskFilePath, userHandoffPath, listTaskRefs, } from '../task-paths.mjs'; const pexec = promisify(execFile); @@ -156,8 +156,8 @@ export function taskPlanTemplate(name) { `; } -// `docs//-handoff.md` 의 유일한 렌더러. 두 지점이 이 파일을 쓴다 — -// 커밋마다 도는 `runHandoffAuto`(활성 형태)와 종결 시점의 `runDone`(종결 형태). 두 곳이 각자 +// `docs//-handoff.md` 의 유일한 렌더러. 세 지점이 이 파일을 쓴다 — 커밋마다 도는 +// `runHandoffAuto`(활성 형태), task 활성화(`writeActivationHandoff`, 활성 형태), 종결 시점의 `runDone`(종결 형태). 각자 // 문자열을 조립하면 형식이 반드시 어긋나므로 여기 하나로 모은다. // // 종결 형태에는 커밋 sha 를 담지 않는다. 종결 후에는 훅이 이 파일을 더는 갱신하지 않으므로 @@ -187,6 +187,14 @@ ${head} `; } +// user handoff 는 추적하지 않는 워크트리 로컬 파일이라(USER_HANDOFF_IGNORE) 새 워크트리·clone 에는 없다. +// 첫 커밋까지 기다리면 세션 진입점이 비므로, 포인터가 옮겨지는 활성화 시점에 활성 형태를 한 번 쓴다. +async function writeActivationHandoff(targetDir, user, task, date) { + await writeFile(userHandoffPath(targetDir, user), renderUserHandoff({ + user, task, date, commitMsg: '(task 활성화 — 다음 커밋에서 갱신)', closed: false, + })); +} + function taskHandoffTemplate(name) { return `# ${name} — Handoff @@ -436,6 +444,7 @@ export async function runTask(ctx, { doneOnMain = checkDoneOnMain } = {}) { path: taskDirRel(user, name), switchedAt, }); + await writeActivationHandoff(ctx.targetDir, user, name, date); if (json) { emitObservation(buildEnvelope({ @@ -472,6 +481,7 @@ export async function runTask(ctx, { doneOnMain = checkDoneOnMain } = {}) { path: taskDirRel(user, name), switchedAt: firstActivatedAt, }); + await writeActivationHandoff(ctx.targetDir, user, name, date); // Per-task state only. The shared ledger (docs/task_summary.md and the user index) // is rendered by `harness-team summary`; writing it here is what made every parallel @@ -694,6 +704,34 @@ const VERIFY_KIND_RE = new RegExp(`-(?:${VERIFY_KIND_SUFFIXES.join('|')})$`); // 셀 때 이 함수를 쓴다. 정규식을 복제하면 접미사 열거가 바뀔 때 사용자가 보는 수와 가드가 세는 수가 갈라진다. // 훅(`runHandoffAuto`)이 **쓰는** 파일이자 `done` 가드가 **무시하는** 파일. 두 곳이 같은 집합을 봐야 // 한다 — 갈라지면 가드는 무시하는데 훅은 계속 써서 churn 이 조용히 되살아난다. +// 아직 **추적 중인** user handoff(전환 전 저장소). 무시 목록에 올려도 git 은 추적 중인 파일을 계속 추적하므로 +// `git rm --cached` 전까지 병렬 PR 충돌은 그대로다 — doctor 가 경고하고 migrate 가 안내하는 근거다. +// 경로는 targetDir 기준이다(`-C targetDir` 의 pathspec·출력 모두 cwd 기준). git 이 없거나 저장소가 아니면 []. +// glob 은 같은 깊이의 다른 `*-handoff.md` 도 맞으므로 파일명이 정확히 `-handoff.md` 인 것만 남긴다 — +// 무관한 추적 파일을 `git rm --cached` 하라고 안내하면 안 된다(2026-09-27 codex P2). +export async function trackedUserHandoffs(targetDir) { + try { + const { stdout } = await pexec('git', ['-C', targetDir, 'ls-files', '-z', '--', `:(glob)${USER_HANDOFF_IGNORE}`], { maxBuffer: 1024 * 1024 }); + return stdout.split('\0').filter(Boolean) + .filter(p => { const [, user, file] = p.split('/'); return file === `${user}-handoff.md`; }) + .sort(); + } catch { + return []; + } +} + +// user handoff 가 이 설치의 무시 규칙에 걸리는가. 추적 해제만 하고 무시 줄이 없으면 다음 `git add -A` 가 다시 +// 추적한다(2026-09-27 codex P2). 실제 user 이름과 무관하게 깊이 2 probe 경로로 판정한다. 저장소가 아니면(128) null. +export async function userHandoffIgnored(targetDir) { + const probe = userHandoffRel('harness-probe'); + try { + await pexec('git', ['-C', targetDir, 'check-ignore', '-q', '--no-index', probe]); + return true; + } catch (err) { + return err && err.code === 1 ? false : null; + } +} + export function handoffRelPaths(user, task) { return new Set([ taskFileRel(user, task, 'handoff.md'), diff --git a/src/harness.mjs b/src/harness.mjs index 560aefe9..dee5d39e 100644 --- a/src/harness.mjs +++ b/src/harness.mjs @@ -7,6 +7,7 @@ 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 } from './render-state.mjs'; +import { USER_HANDOFF_IGNORE } from './task-paths.mjs'; export const DEFAULT_BACKUP_PARENT = 'harness-backup'; @@ -373,7 +374,7 @@ const AI_GITIGNORE_ENTRIES = [ '*.log', ]; -async function appendGitignore(targetDir, { addAiEntries = false } = {}) { +export async function appendGitignore(targetDir, { addAiEntries = false } = {}) { const { readTextSafe, writeText } = await import('./fsx.mjs'); const path = join(targetDir, '.gitignore'); const existing = (await readTextSafe(path)) ?? ''; @@ -385,13 +386,15 @@ async function appendGitignore(targetDir, { addAiEntries = false } = {}) { // 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 / // CLAUDE.md taken before a bootstrap overwrite — a local recovery aid, not a artifact to - // commit (codex review P2). + // commit (codex review P2). The user handoff is a worktree-local rendering of active.json — see + // USER_HANDOFF_IGNORE for why tracking it made every parallel PR conflict. const harnessNeeded = [ '.claude/settings.local.json', '.harness/active.json', '.harness/config.json', '.harness/observability/', '.harness/backup/', + USER_HANDOFF_IGNORE, ]; const harnessMissing = harnessNeeded.filter(line => !has(line)); @@ -407,7 +410,9 @@ async function appendGitignore(targetDir, { addAiEntries = false } = {}) { } } - if (out !== existing) await writeText(path, out); + if (out === existing) return false; + await writeText(path, out); + return true; } export const AI_GITIGNORE_PREVIEW = AI_GITIGNORE_ENTRIES.join('\n'); diff --git a/src/task-paths.mjs b/src/task-paths.mjs index b1bc52e4..5b9869d2 100644 --- a/src/task-paths.mjs +++ b/src/task-paths.mjs @@ -21,6 +21,13 @@ export const taskDirRel = (user, task) => `${DOCS_DIR}/${user}/${task}`; // kind: 'spec.md' | 'plan.md' | 'handoff.md' | 'artifact.md' | 'context.md' | 'meta.json' | 'diagram.html' export const taskFileRel = (user, task, kind) => `${taskDirRel(user, task)}/${task}-${kind}`; export const userHandoffRel = (user) => `${DOCS_DIR}/${user}/${user}-handoff.md`; +// user handoff 는 워크트리 로컬 렌더링이라 추적하지 않는다 — 내용이 전부 gitignore 된 active.json 과 그 워크트리의 +// `git log -1` 에서 나오므로, 추적하면 같은 user 의 병렬 PR 이 머지마다 이 파일에서 충돌했다(3323c6f·fdcffbc·a7c8354). +// 슬래시가 중간에 있어 .gitignore 위치에 고정되고 `*` 는 `/` 를 넘지 않는다 — 깊이 2 만 맞고 task handoff +// (`docs///-handoff.md`, SSOT)는 맞지 않는다. gitignore 는 "파일명 = 디렉터리명-handoff.md" 를 표현할 수 없어 +// 같은 깊이의 다른 `*-handoff.md`(예: `docs/

/-handoff.md`)도 새로 만들면 무시된다 — 이미 추적 중인 파일은 영향 없다. +// 추적 해제 **안내**는 정확한 `/-handoff.md` 만 대상으로 한다(`trackedUserHandoffs`). +export const USER_HANDOFF_IGNORE = `${DOCS_DIR}/*/*-handoff.md`; // 출력·원장 키·규칙 유래 마커가 쓰는 task 식별자. user/task 모두 ^[\w.-]+$ 라 `/` 가 들어갈 수 없어 모호하지 않다. export const taskLabel = (user, task) => `${user}/${task}`; diff --git a/templates/AGENTS.md.hbs b/templates/AGENTS.md.hbs index 5c71b497..c398af1f 100644 --- a/templates/AGENTS.md.hbs +++ b/templates/AGENTS.md.hbs @@ -127,7 +127,8 @@ SSOT는 여전히 네 파일이며, TCC는 거기서 파생된 현재 working se - **경계 계약**: spec의 `## Boundary contracts` JSON 선언이 있으면 plan checkbox 완료 직전에 `harness-team boundary check`가 생산자·소비자 JSON Schema의 필수 필드와 기본 type을 대조한다. 선언이 없으면 `boundary: not-configured`으로 통과한다. -- **commit 시**: post-commit hook이 handoff 2파일 갱신 — 다음 커밋에 담는다(예외: harness-task) +- **commit 시**: post-commit hook이 task handoff를 갱신 — 다음 커밋에 담는다(예외: harness-task). + `-handoff.md`도 함께 갱신되지만 워크트리 로컬(gitignore)이라 커밋하지 않는다 - **PR/MR 직전(ship)**: spec·plan·artifact를 최종 갱신하고 준비 완료를 보고한다 — 다이어그램 갱신은 옵트인이며, 절차·산출물 계약은 ship 명령 문서가 정본이다(PR/MR 생성은 별도 지시). - **완료**: plan 전체 완료 감지 또는 사용자 신호 → AskUserQuestion → `harness-team done` @@ -158,7 +159,7 @@ SSOT는 여전히 네 파일이며, TCC는 거기서 파생된 현재 working se | `CLAUDE.md` | `@AGENTS.md` import + Claude 전용 (워크플로우·서브에이전트·advisor) | | `docs///` | task별 작업 문서 4종 (spec·plan·handoff·artifact) | | `.harness/active.json` | 현재 활성 task 포인터 | -| `docs//-handoff.md` | 세션 시작 진입점 | +| `docs//-handoff.md` | 세션 시작 진입점 (워크트리 로컬·gitignore — task 활성화·커밋 때 갱신) | | `docs/decisions.md` | 팀 결정 로그 — 역할·프로토콜 규범의 근거·이력 정본 | | `.claude/rules/*.md` | 영역별 코딩 규칙 | diff --git a/templates/docs/README.md b/templates/docs/README.md index 56d55009..439a7c99 100644 --- a/templates/docs/README.md +++ b/templates/docs/README.md @@ -17,7 +17,7 @@ task별 디렉토리 하나에 SSOT 4개 파일과 비-SSOT Context Card 1개가 docs/ ├── task_summary.md # 전체 task 요약 표 — 생성물 (harness-team summary) └── / # git config user.name 또는 $USER - ├── -handoff.md # 세션 시작 진입점 (현재 active task) + ├── -handoff.md # 세션 시작 진입점 (현재 active task) — 워크트리 로컬, gitignore ├── -task.md # 이 사용자의 task 인덱스 — 생성물 (harness-team summary) └── / # task 디렉토리 ├── -spec.md # 요구사항 / 설계 (사람이 먼저 작성) diff --git a/tests/gitignore-entries.test.mjs b/tests/gitignore-entries.test.mjs index 6cc977c1..e05f24a7 100644 --- a/tests/gitignore-entries.test.mjs +++ b/tests/gitignore-entries.test.mjs @@ -17,7 +17,7 @@ test('.gitignore: .harness/를 통째로 무시하지 않고 개인 상태 파 try { await copyStaticAssets({ root: ROOT, targetDir: dir, flags: {}, stackId: 'node', addAiGitignore: true }); 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/']) { + for (const wanted of ['.claude/settings.local.json', '.harness/active.json', '.harness/config.json', '.harness/observability/', 'docs/*/*-handoff.md']) { assert.ok(lines.includes(wanted), `${wanted} 는 무시돼야 한다`); } assert.ok(!lines.includes('.harness/'), '.harness/ 전체를 무시하면 backup.json을 커밋할 수 없다'); diff --git a/tests/summary.test.mjs b/tests/summary.test.mjs index 60fa7c24..db98e382 100644 --- a/tests/summary.test.mjs +++ b/tests/summary.test.mjs @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { runTask, runDone } from '../src/commands/task.mjs'; +import { USER_HANDOFF_IGNORE } from '../src/task-paths.mjs'; import { collectTasks, renderTaskSummary, renderUserIndex, runSummary, readTaskMeta, defaultBranchCandidates, readLedger, taskMetaTemplate, writeTaskMeta as writeTaskMetaForTest, @@ -21,9 +22,9 @@ async function initRepo(dir) { await git(dir, 'init', '-q', '-b', 'main'); await git(dir, 'config', 'user.email', 'test@example.com'); await git(dir, 'config', 'user.name', 'test'); - // Real installs gitignore .harness/; without it the active-task pointer would be - // committed and would itself collide across branches. - await writeFile(join(dir, '.gitignore'), '.harness/\n'); + // Real installs gitignore .harness/ and the worktree-local user handoff; without them the + // active-task pointer (and its rendering) would be committed and collide across branches. + await writeFile(join(dir, '.gitignore'), `.harness/\n${USER_HANDOFF_IGNORE}\n`); await writeFile(join(dir, 'README.md'), '# seed\n'); await git(dir, 'add', '-A'); await git(dir, 'commit', '-qm', 'seed'); diff --git a/tests/user-handoff-untracked.test.mjs b/tests/user-handoff-untracked.test.mjs new file mode 100644 index 00000000..eebbcc05 --- /dev/null +++ b/tests/user-handoff-untracked.test.mjs @@ -0,0 +1,209 @@ +// `docs//-handoff.md` 는 **워크트리 로컬 렌더링**이다 — 내용이 전부 gitignore 된 `.harness/active.json` +// 과 그 워크트리의 `git log -1` 에서 나온다. 추적하던 시절에는 같은 user 의 병렬 PR 이 머지될 때마다 이 파일에서 +// 반드시 충돌했다(3323c6f·fdcffbc·a7c8354). 여기서는 추적 해제 이후의 계약을 고정한다: +// - init 이 쓰는 .gitignore 가 깊이 2 의 user handoff 만 무시하고 task handoff 는 추적한다 +// - 병렬 브랜치가 각자 커밋·sweep 한 뒤 머지해도 충돌하지 않는다 +// - sweep(#105)·done 가드(#109)가 무시 파일을 "훅 출력" 으로 올바로 다룬다 +// - task 활성화가 user handoff 를 곧바로 써서 새 워크트리에 공백이 없다 +// - 아직 추적 중인 저장소는 doctor 가 경고하고 migrate 는 인덱스를 건드리지 않고 안내만 한다 +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { + runTask, runHandoffAuto, runDone, taskArtifactTemplate, trackedUserHandoffs, +} from '../src/commands/task.mjs'; +import { USER_HANDOFF_IGNORE } from '../src/task-paths.mjs'; +import { appendGitignore } from '../src/harness.mjs'; +import { checkTrackedUserHandoffs } from '../src/commands/doctor.mjs'; +import { migrateUserHandoffUntrack } from '../src/commands/migrate.mjs'; + +const pexec = promisify(execFile); +const git = (dir, ...args) => pexec('git', ['-C', dir, ...args]); +const USER = 'tester'; +const noRemote = async () => null; + +function quiet() { + const logs = []; + const orig = console.log; + console.log = (...a) => logs.push(a.join(' ')); + return { logs, restore: () => { console.log = orig; } }; +} + +async function makeRepo() { + const dir = await mkdtemp(join(tmpdir(), 'harness-uh-')); + await git(dir, 'init', '-q', '-b', 'main'); + await git(dir, 'config', 'user.email', 't@e.com'); + await git(dir, 'config', 'user.name', 't'); + await mkdir(join(dir, '.harness'), { recursive: true }); + await appendGitignore(dir); + await writeFile(join(dir, 'src.txt'), 'v1\n'); + await git(dir, 'add', '-A'); + await git(dir, 'commit', '-qm', 'base'); + return dir; +} + +async function startTask(dir, name) { + const q = quiet(); + try { await runTask({ targetDir: dir, flags: { member: USER }, taskArgs: [name] }, { doneOnMain: noRemote }); } + finally { q.restore(); } +} + +// 실제 작업 커밋 → post-commit 훅 → 다음 커밋에 남은 변경을 담는 흐름(#105)을 그대로 밟는다. +async function commitWithHook(dir, msg) { + await git(dir, 'add', '-A'); + await git(dir, 'commit', '-qm', msg); + await runHandoffAuto({ targetDir: dir }); +} + +const porcelain = async (dir) => (await git(dir, 'status', '--porcelain')).stdout.trim(); + +test('.gitignore: user handoff(깊이 2)는 무시하고 task handoff 는 추적한다', async () => { + const dir = await makeRepo(); + try { + const lines = (await readFile(join(dir, '.gitignore'), 'utf8')).split('\n'); + assert.ok(lines.includes(USER_HANDOFF_IGNORE), `${USER_HANDOFF_IGNORE} 줄이 있어야 한다`); + const ignored = (p) => git(dir, 'check-ignore', '-q', '--no-index', p).then(() => true, () => false); + assert.equal(await ignored(`docs/${USER}/${USER}-handoff.md`), true); + assert.equal(await ignored(`docs/${USER}/demo/demo-handoff.md`), false, 'task handoff 는 SSOT — 추적한다'); + } finally { await rm(dir, { recursive: true, force: true }); } +}); + +test('task 생성·재활성화가 곧바로 user handoff 를 쓴다 — 새 워크트리 공백 없음', async () => { + const dir = await makeRepo(); + const userHandoff = join(dir, 'docs', USER, `${USER}-handoff.md`); + try { + await startTask(dir, 'alpha'); + assert.match(await readFile(userHandoff, 'utf8'), /## Active Task\nalpha\n/, '생성 직후 활성 형태'); + await startTask(dir, 'beta'); + await startTask(dir, 'alpha'); + const after = await readFile(userHandoff, 'utf8'); + assert.match(after, /## Active Task\nalpha\n/, '재활성화도 포인터를 옮긴다'); + assert.match(after, /→ docs\/tester\/alpha\/alpha-handoff\.md/); + } finally { await rm(dir, { recursive: true, force: true }); } +}); + +test('같은 user 의 병렬 브랜치가 각자 커밋·sweep 후 머지해도 충돌하지 않는다', async () => { + const dir = await makeRepo(); + try { + await git(dir, 'switch', '-qc', 'feat-a'); + await startTask(dir, 'alpha'); + await writeFile(join(dir, 'a.txt'), 'a\n'); + await commitWithHook(dir, 'feat: a'); + await commitWithHook(dir, 'chore(handoff): sweep'); + + await git(dir, 'switch', '-q', 'main'); + await git(dir, 'switch', '-qc', 'feat-b'); + await startTask(dir, 'beta'); + await writeFile(join(dir, 'b.txt'), 'b\n'); + await commitWithHook(dir, 'feat: b'); + await commitWithHook(dir, 'chore(handoff): sweep'); + + await git(dir, 'switch', '-q', 'main'); + await git(dir, 'merge', '-q', '--no-ff', '-m', 'merge a', 'feat-a'); + await git(dir, 'merge', '-q', '--no-ff', '-m', 'merge b', 'feat-b'); // 충돌하면 throw + const { stdout } = await git(dir, 'log', '--all', '--name-only', '--format='); + assert.ok(!stdout.split('\n').includes(`docs/${USER}/${USER}-handoff.md`), 'user handoff 는 어느 커밋에도 없다'); + } finally { await rm(dir, { recursive: true, force: true }); } +}); + +test('sweep(#105): 작업 커밋 뒤 남는 변경은 task handoff 하나이고, 그것만 담은 커밋에서 훅은 침묵한다', async () => { + const dir = await makeRepo(); + try { + await startTask(dir, 'alpha'); + await commitWithHook(dir, 'feat: work'); + assert.equal(await porcelain(dir), 'M docs/tester/alpha/alpha-handoff.md', '무시 파일은 dirty 로 보이지 않는다'); + await commitWithHook(dir, 'chore(handoff): sweep'); + assert.equal(await porcelain(dir), '', 'sweep 뒤 트리가 깨끗하다 — 루프가 끝난다'); + } finally { await rm(dir, { recursive: true, force: true }); } +}); + +test('done 가드(#109): 무시된 user handoff 와 dirty task handoff 만 남으면 종결을 막지 않는다', async () => { + const dir = await makeRepo(); + try { + await startTask(dir, 'alpha'); + const taskDir = join(dir, 'docs', USER, 'alpha'); + await writeFile(join(taskDir, 'alpha-plan.md'), '# alpha — Plan\n\n## 단계\n- [x] done\n'); + await writeFile(join(taskDir, 'alpha-artifact.md'), taskArtifactTemplate('alpha') + '\n- 실제 결과\n'); + await writeFile(join(taskDir, 'alpha-spec.md'), '# alpha — Spec\n\n## Done evidence\n```json\n{ "version": 1, "tests": "skip" }\n```\n'); + await commitWithHook(dir, 'feat: work'); + const q = quiet(); + try { await runDone({ targetDir: dir, flags: {} }); } finally { q.restore(); } + assert.ok(q.logs.some(l => l.startsWith('done:')), q.logs.join('\n')); + assert.match(await readFile(join(dir, 'docs', USER, `${USER}-handoff.md`), 'utf8'), /Last Completed Task/, '종결 형태는 로컬에 쓴다'); + } finally { process.exitCode = undefined; await rm(dir, { recursive: true, force: true }); } +}); + +test('전환 전 저장소: 추적 중인 user handoff 를 doctor 가 경고하고 migrate 는 인덱스를 건드리지 않고 안내한다', async () => { + const dir = await mkdtemp(join(tmpdir(), 'harness-uh-legacy-')); + try { + await git(dir, 'init', '-q', '-b', 'main'); + await git(dir, 'config', 'user.email', 't@e.com'); + await git(dir, 'config', 'user.name', 't'); + await mkdir(join(dir, 'docs', USER, 'alpha'), { recursive: true }); + await writeFile(join(dir, 'docs', USER, `${USER}-handoff.md`), '# Session Handoff\n'); + await writeFile(join(dir, 'docs', USER, 'alpha', 'alpha-handoff.md'), '# alpha — Handoff\n'); + await git(dir, 'add', '-A'); + await git(dir, 'commit', '-qm', 'legacy'); + + assert.deepEqual(await trackedUserHandoffs(dir), [`docs/${USER}/${USER}-handoff.md`], 'task handoff 는 대상이 아니다'); + assert.match(await checkTrackedUserHandoffs(dir), /git rm --cached docs\/tester\/tester-handoff\.md/); + + const indexBefore = (await git(dir, 'ls-files', '-s')).stdout; + const q = quiet(); + let changed; + try { changed = await migrateUserHandoffUntrack({ targetDir: dir }); } finally { q.restore(); } + assert.equal(changed, true); + assert.equal((await git(dir, 'ls-files', '-s')).stdout, indexBefore, '인덱스 무변경'); + assert.ok(q.logs.some(l => l.includes('git rm --cached docs/tester/tester-handoff.md')), q.logs.join('\n')); + assert.ok((await readFile(join(dir, '.gitignore'), 'utf8')).split('\n').includes(USER_HANDOFF_IGNORE)); + + await git(dir, 'rm', '-q', '--cached', `docs/${USER}/${USER}-handoff.md`); + await git(dir, 'commit', '-qm', 'untrack'); + assert.deepEqual(await trackedUserHandoffs(dir), []); + assert.equal(await checkTrackedUserHandoffs(dir), null, '전환 뒤에는 경고하지 않는다'); + const q2 = quiet(); + try { assert.equal(await migrateUserHandoffUntrack({ targetDir: dir }), false, '재실행은 no-op'); } finally { q2.restore(); } + } finally { await rm(dir, { recursive: true, force: true }); } +}); + +test('추적 해제만 되고 무시 줄이 없는 저장소: doctor 가 경고하고 migrate 가 줄을 넣는다 (codex P2)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'harness-uh-noignore-')); + try { + await git(dir, 'init', '-q', '-b', 'main'); + assert.deepEqual(await trackedUserHandoffs(dir), []); + assert.match(await checkTrackedUserHandoffs(dir), /\.gitignore 에 `docs\/\*\/\*-handoff\.md` 줄이 없어/); + const q = quiet(); + let changed; + try { changed = await migrateUserHandoffUntrack({ targetDir: dir }); } finally { q.restore(); } + assert.equal(changed, true, '추적 사본이 없어도 무시 줄은 보장한다'); + assert.equal(await checkTrackedUserHandoffs(dir), null); + } finally { await rm(dir, { recursive: true, force: true }); } +}); + +test('같은 깊이의 다른 *-handoff.md 는 추적 해제 안내 대상이 아니다 (codex P2)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'harness-uh-other-')); + try { + await git(dir, 'init', '-q', '-b', 'main'); + await git(dir, 'config', 'user.email', 't@e.com'); + await git(dir, 'config', 'user.name', 't'); + await mkdir(join(dir, 'docs', 'diagrams'), { recursive: true }); + await mkdir(join(dir, 'docs', USER), { recursive: true }); + await writeFile(join(dir, 'docs', 'diagrams', 'release-handoff.md'), 'x\n'); + await writeFile(join(dir, 'docs', USER, `${USER}-handoff.md`), 'x\n'); + await git(dir, 'add', '-A'); + await git(dir, 'commit', '-qm', 'seed'); + assert.deepEqual(await trackedUserHandoffs(dir), [`docs/${USER}/${USER}-handoff.md`]); + } finally { await rm(dir, { recursive: true, force: true }); } +}); + +test('git 저장소가 아니면 추적 판정은 빈 목록 — doctor 는 조용하다', async () => { + const dir = await mkdtemp(join(tmpdir(), 'harness-uh-nogit-')); + try { + assert.deepEqual(await trackedUserHandoffs(dir), []); + assert.equal(await checkTrackedUserHandoffs(dir), null); + } finally { await rm(dir, { recursive: true, force: true }); } +});