# mixed-commit-detector-spec.md — `scripts/mixed_commit_detector.py` 명세

> 버전: 1.0 | 작성일: 2026-05-05 | 작성자: 엔키 (dev5팀 백엔드)
> Task: task-2459 Phase 2-C
> 상태: 명세 (구현은 Phase 2-D 통합 시)

---

## 0. 문서 목적과 범위

본 문서는 **`scripts/mixed_commit_detector.py`** 의 동작을 명세한다.
이 도구는 한 task 브랜치(`task/<task-id>-<bot>`) 위에 **다른 task 의 commit 이 섞였는지**
탐지하는 read-only 분석기다.

탐지가 적중하면:

- `.tasks/locks/<task-id>.frozen` 마커를 **생성**(remove 금지) 하여 후속 commit/push/finish 차단
- `.tasks/evidence/<task-id>/mixed-commit-<timestamp>.json` evidence 저장
- exit 1 보고

본 spec은 다음을 다룬다.

- 탐지 알고리즘 (5단계)
- 5가지 mixed 시나리오
- evidence·freeze 마커 포맷
- CLI 인터페이스
- ★ 자동 복구 금지 정책

---

## 1. 배경 (Why)

### 1.1 태생적 위험

봇이 단일 worktree 안에서 작업 중이라도, 외부 요인으로 다른 task commit 이 섞일 수 있다:

- 봇이 잘못된 base 에서 분기 (예: 다른 task 브랜치 위에서 시작)
- 사람/봇이 수동 cherry-pick 으로 commit 옮김
- 봇이 메시지 prefix 를 잘못 적음 (이전 task id 그대로 사용)
- merge 가 multiple task 를 한 번에 끌어들임
- handoff 시 base 가 origin/main 이 아닌 stale ref 였음

### 1.2 silent corruption 과의 연결

task-2452 사고는 "메인 워크스페이스에서 직접 작업"이 핵심 원인이었지만,
사후 분석에서 **commit 메시지에 다른 task-id 가 포함된 commit** 이 같은 브랜치에서
발견되었다. 이 commit 들은 머지 시 의도하지 않은 변경을 끌어들일 수 있었다.

본 도구는 그 시나리오를 **머지 전, 가능하면 commit 직후** 에 잡는다.

### 1.3 본 도구의 위치

```
원격 origin/main
        │
        ▼
  task/<id>-<bot>     ← 이 브랜치 위 commit 들의 메시지를 검사
        │
   detector ──► alien task token 발견? ──► freeze + evidence + exit 1
                                  │
                                  └► clean ──► exit 0
```

`taskctl_verify.py` 의 9번 검사가 본 detector 를 호출한다.
git pre-commit / pre-push hook (Phase 2-A) 도 freeze 마커를 검사하여 차단한다.

---

## 2. 탐지 알고리즘 (How)

### 2.1 알고리즘 5단계

**Step 1 — base..HEAD commit 수집**

```bash
git log origin/main..HEAD --pretty=format:'%H|%s'
```

- `origin/main` 이 base reference (필요 시 `--base <ref>` 로 override 가능)
- 빈 출력 → branch 가 origin/main 과 동일 → exit 0 (clean)

**Step 2 — subject 에서 task token 추출**

각 commit subject (full message body 가 아닌 `%s` 의 결과) 에 대해:

```python
import re
TASK_RE = re.compile(r"\[(task-\w+)\]")
tokens = re.findall(TASK_RE, subject)
```

★ **메시지 전체에서 매치**, prefix anchor (`^...`) 사용 **금지**.
이유: 한 commit subject 에 다중 token 이 섞이는 케이스(`[task-2459] WIP, follows [task-2452]`)
까지 잡아야 한다.

★ `\w+` 는 task-id 본체. `task-2459`, `task-2459a`, `task-240.1` 같은 변형을 모두 잡으려면
다음을 검토:

- 본 spec v1.0 에서는 `[task-\w+(?:\.\w+)?]` 형태도 허용 (`\.` 1단계까지 capture).
- 추후 task-id 컨벤션 확장 시 본 spec 업데이트 필요.

**Step 3 — alien token 집합 계산**

```python
all_tokens = set(tokens_per_commit.flatten())
alien_tokens = all_tokens - {本_task_id}
```

- alien_tokens 가 비면 → clean
- 비지 않으면 → mixed 감지

**Step 4 (mixed 감지 시) — 마커·evidence 생성**

본 step 의 부수효과:

1. `.tasks/locks/<task-id>.frozen` 마커 생성 (이미 있으면 timestamp 갱신, 마커 자체는 유지)
2. `.tasks/evidence/<task-id>/mixed-commit-<ts>.json` evidence 저장
3. stdout 에 단축 JSON 요약
4. exit 1

**Step 5 — clean (or empty)**

- alien_tokens 가 비고 commit 도 0 개면 stdout `{"status":"empty"}` 후 exit 0
- alien_tokens 가 비고 commit 1+ 개면 stdout `{"status":"clean"}` 후 exit 0
- 두 경우 모두 freeze 마커 생성하지 **않음**

### 2.2 알고리즘 의사코드

```python
def detect(task_id: str, branch_ref: str = "HEAD",
           base_ref: str = "origin/main",
           dry_run: bool = False) -> int:
    commits = git_log(base_ref, branch_ref)  # list of (sha, subject)
    if not commits:
        emit({"status": "empty", "task_id": task_id})
        return 0

    TASK_RE = re.compile(r"\[(task-\w+(?:\.\w+)?)\]")
    per_commit = []
    all_tokens = set()
    for sha, subject in commits:
        tokens = TASK_RE.findall(subject)
        per_commit.append({"sha": sha, "subject": subject, "tokens": tokens})
        all_tokens.update(tokens)

    alien = sorted(all_tokens - {task_id})
    if not alien:
        emit({"status": "clean", "task_id": task_id, "n_commits": len(commits)})
        return 0

    payload = build_payload(task_id, alien, per_commit, base_ref, branch_ref)

    if dry_run:
        emit(payload)
        return 1  # detection still reported, but no side effects

    write_freeze_marker(task_id, payload)
    write_evidence(task_id, payload)
    emit_summary(payload)
    return 1
```

### 2.3 `git log` 사용 시 주의

- `git log origin/main..HEAD --pretty=format:'%H|%s'`
- subject 에 `|` 가 들어갈 수 있으므로 splitting 시 `partition('|')` (첫 `|` 만) 사용
- 더 안전한 대안: NUL 종료 포맷 `--pretty=format:%H%x00%s%x00` (구현 시 권장)

---

## 3. 5가지 mixed 시나리오

본 detector 가 잡아야 하는 (또는 무관함을 명확히 해야 하는) 5 시나리오.

### 시나리오 S1 — 단일 alien commit

브랜치: `task/2459-enki`
log:

```
abc111 [task-2459] add spec doc
abc222 [task-2452] hotfix typo   ← alien
abc333 [task-2459] add tests
```

기대:

- alien_tokens = {"task-2452"}
- mixed=1
- evidence.commits 에 3개 모두 기록 (alien 표시)
- exit 1

### 시나리오 S2 — 다중 alien token in one subject

```
abc111 [task-2459] follow up to [task-2452] cleanup
```

subject 내 두 token 매치 → tokens=["task-2459","task-2452"]
alien_tokens = {"task-2452"}
mixed=1, exit 1.

### 시나리오 S3 — origin/main..HEAD 비어있음

브랜치가 origin/main 과 동일 (직후 fetch 한 깨끗한 worktree).

```
$ git log origin/main..HEAD
(empty)
```

기대: status=empty, exit 0, freeze 마커 생성 안함.

### 시나리오 S4 — 모두 본 task 토큰만

```
abc111 [task-2459] step 1
abc222 [task-2459] step 2
abc333 [task-2459] step 3
```

alien_tokens = ∅. status=clean, exit 0.

### 시나리오 S5 — token 누락 commit (legacy / hotfix)

```
abc111 [task-2459] proper commit
abc222 chore: rename file        ← token 없음
abc333 [task-2459] proper commit
```

기대: token 없는 commit 은 alien 으로 **간주하지 않음** (token 부재 = unknown, not alien).
다만 evidence 에 `untagged_commits` 카운트로 기록한다 (참고 정보).
overall: clean, exit 0. 단, untagged 가 1개 이상이면 stdout 의 status 에 `clean_with_untagged`
으로 표시 (호출자 `taskctl_verify.py` 의 mixed_commit 검사는 PASS 로 간주, 별도 hint).

> 정책 근거: token 없는 commit 을 alien 으로 처리하면 false positive 가 폭증하여 운영 마비.
> Token 누락은 별도 정책 (commit lint hook) 으로 다룬다.

### 시나리오 S6 (참고 — 외부) — 본 task 토큰이 단 하나도 없음

```
abc111 [task-2452] foreign work
abc222 [task-2452] foreign work
```

alien_tokens = {"task-2452"}, 본 task token 0건.
브랜치 자체가 잘못 매핑된 것으로 간주, mixed=1, exit 1.
(시나리오 S1 의 극단적 형태이므로 별도 케이스로 처리 필요 없음. evidence 에서
`本_task_token_count: 0` 로 시각화)

---

## 4. freeze 마커 포맷

### 4.1 경로

```
<workspace_root>/.tasks/locks/<task-id>.frozen
```

### 4.2 스키마

```json
{
  "task_id": "task-2459",
  "frozen_at": "2026-05-05T18:21:09Z",
  "detector_version": "1.0",
  "branch_ref": "HEAD",
  "resolved_branch": "task/2459-enki",
  "base_ref": "origin/main",
  "base_sha": "e51cf8332379e7234fc800aabe62ff60838cf896",
  "head_sha": "92e0c39f630b4d1a44836a74c72a9a70169796f3",
  "mixed_tasks": ["task-2459", "task-2452"],
  "alien_tasks": ["task-2452"],
  "本_task_token_count": 2,
  "alien_token_count": 1,
  "untagged_commit_count": 0,
  "commits": [
    {"sha": "abc111", "subject": "[task-2459] add spec", "tokens": ["task-2459"], "alien": false},
    {"sha": "abc222", "subject": "[task-2452] hotfix typo", "tokens": ["task-2452"], "alien": true},
    {"sha": "abc333", "subject": "[task-2459] add tests", "tokens": ["task-2459"], "alien": false}
  ]
}
```

### 4.3 마커 의미

| 시점                  | 차단되는 행위                                    |
|-----------------------|-------------------------------------------------|
| git pre-commit hook   | 새 commit 생성 (Phase 2-A 가 마커 검사)         |
| git pre-push hook     | 원격 push                                       |
| `taskctl finish`      | finish 단계 진입 (taskctl 이 마커 검사)         |
| `taskctl_verify.py`   | mixed_commit 검사 FAIL 보고 (재호출)            |

### 4.4 마커 제거 정책 ★ 중요

- 봇은 **자체 제거 절대 금지**
- 회장 또는 아누(시스템 운영자)만 수동 제거 가능
- 제거는 운영 절차(별도 SOP) 에 따라:
  1. 회장이 commit history 점검
  2. mixed commit 의 책임 task 에 escalation
  3. 안전한 분리(rebase·cherry-pick) 가 사람 손으로 완료된 후
  4. 회장이 마커 파일을 직접 삭제

---

## 5. evidence JSON 포맷

### 5.1 경로

```
<workspace_root>/.tasks/evidence/<task-id>/mixed-commit-<YYYYMMDDTHHMMSSZ>.json
```

### 5.2 스키마

freeze 마커와 거의 동일하되 다음 필드를 추가:

```json
{
  "task_id": "task-2459",
  "verified_at": "2026-05-05T18:21:09Z",
  "detector_version": "1.0",
  "branch_ref": "HEAD",
  "resolved_branch": "task/2459-enki",
  "base_ref": "origin/main",
  "base_sha": "e51cf8332379e7234fc800aabe62ff60838cf896",
  "head_sha": "92e0c39f630b4d1a44836a74c72a9a70169796f3",
  "mixed": true,
  "mixed_tasks": ["task-2459", "task-2452"],
  "alien_tasks": ["task-2452"],
  "本_task_token_count": 2,
  "alien_token_count": 1,
  "untagged_commit_count": 0,
  "commits": [
    {"sha": "abc111", "subject": "[task-2459] add spec", "tokens": ["task-2459"], "alien": false},
    {"sha": "abc222", "subject": "[task-2452] hotfix typo", "tokens": ["task-2452"], "alien": true},
    {"sha": "abc333", "subject": "[task-2459] add tests", "tokens": ["task-2459"], "alien": false}
  ],
  "escalation_message": "task-2459 브랜치에서 alien commit 1건 감지 (task-2452). .tasks/locks/task-2459.frozen 생성됨. 회장/아누만 수동 처리.",
  "freeze_marker_path": ".tasks/locks/task-2459.frozen",
  "exit_code": 1
}
```

### 5.3 clean evidence (저장 안함)

clean / empty 의 경우 evidence 파일은 **생성하지 않는다**.
stdout 으로만 단축 JSON 출력. (디스크 사용량 최소화 + clean 은 정상이므로 기록 불필요.)

---

## 6. CLI 인터페이스

### 6.1 기본 호출

```
python3 scripts/mixed_commit_detector.py <task-id>
```

- branch = HEAD
- base = origin/main
- mixed 감지 시 freeze 마커 + evidence 저장 + exit 1
- clean 시 stdout 단축 + exit 0

### 6.2 `--branch <ref>` (옵션)

```
python3 scripts/mixed_commit_detector.py task-2459 --branch refs/heads/task/2459-enki
```

- 다른 ref 검사. 사람이 수동 점검 시 또는 CI 가 PR head 를 검사할 때 사용.

### 6.3 `--base <ref>` (옵션, 권장)

```
python3 scripts/mixed_commit_detector.py task-2459 --base origin/develop
```

- base override (기본: `origin/main`)
- 일반적으로는 사용 금지 — 정책상 base 는 origin/main 고정

### 6.4 `--json` (옵션, dry-run 효과)

```
python3 scripts/mixed_commit_detector.py task-2459 --json
```

- evidence 파일 저장 안함, freeze 마커 생성 안함
- stdout 에 전체 payload JSON 출력
- exit code 는 동일 (1=mixed, 0=clean)
- 사용 사례: 사전 확인, CI 미리보기

### 6.5 `--workspace <path>` (옵션)

워크스페이스 루트 명시. 기본값: `WORKSPACE_ROOT` env 또는 `/home/jay/workspace`.

★ **중요 — workspace 와 git_dir 의 분리**

- `--workspace` 는 **evidence/freeze 파일을 어디에 저장할지** 만 결정한다.
- git 조회(log/rev-parse/symbolic-ref) 는 **`--git-dir`** 또는 (미지정 시) 현재 cwd 에서
  수행한다.
- 운영 환경에서는 `workspace = main repo root`, `git_dir = task worktree` 인 경우가 일반적이다.
  workspace 와 cwd 가 같은 디렉토리라고 가정하면 잘못된 위치에서 `git log origin/main..HEAD`
  를 실행하여 false-clean 을 보고할 위험이 있다.
- 따라서 `taskctl_verify.py` 가 detector 를 호출할 때는 항상 `--git-dir <cwd>` 를 명시한다.

### 6.6 `--git-dir <path>` (옵션)

git 조회 디렉토리 (task worktree 의 경로). 미지정 시 detector 의 현재 cwd 를 사용.

```
python3 scripts/mixed_commit_detector.py task-2459 \
    --workspace /home/jay/workspace \
    --git-dir /home/jay/workspace/.worktrees/task-2459-enki
```

- `git log <base>..HEAD`, `git rev-parse`, `git symbolic-ref` 가 모두 이 경로에서 실행된다.
- evidence/freeze 저장 경로는 `--workspace` 가 결정한다 (분리 의도).

### 6.7 `--quiet` (옵션)

stdout 출력 억제. evidence·freeze 마커 동작은 유지.

### 6.8 종료 후 출력 예시

```
$ python3 scripts/mixed_commit_detector.py task-2459
{"status":"mixed","task_id":"task-2459","alien_tasks":["task-2452"],"freeze":".tasks/locks/task-2459.frozen","evidence":".tasks/evidence/task-2459/mixed-commit-20260505T182109Z.json"}
$ echo $?
1
```

---

## 7. exit code 규약

| 코드 | 의미                                  |
|------|--------------------------------------|
| 0    | clean / empty (mixed 없음)            |
| 1    | mixed 감지 (freeze + evidence 저장)   |
| 2    | internal error (git fail, IO 실패 등) |

---

## 8. ★ 자동 복구 금지 정책

### 8.1 명문화

본 detector 는 다음 행위를 **절대** 수행하지 않는다.

| 금지 행위                            | 사유                                         |
|--------------------------------------|----------------------------------------------|
| `git rebase` / `git rebase -i`       | history 자동 변경은 silent corruption 마스킹 |
| `git cherry-pick`                    | alien commit 자동 분리 시도 금지              |
| `git reset` (모든 형태)              | HEAD/index/worktree 자동 변경 금지            |
| `git filter-branch` / `git filter-repo` | 자동 history rewrite 금지                  |
| `git revert`                         | 자동 revert 금지                              |
| LLM API 호출 (Anthropic/GLM 등)      | 결정 자동화 금지                              |
| `.frozen` 마커 자동 제거             | 회장/아누만 수동 제거                         |
| 외부 네트워크 호출                   | git fetch 포함                                |

### 8.2 detector 가 호출하는 외부 명령

다음만 허용:

- `git log <base>..<branch> --pretty=format:...` (read-only)
- `git rev-parse <ref>` (read-only, sha 해석용)
- `git symbolic-ref HEAD` (read-only, branch 해석용)

이 외 모든 git 변경 명령 금지. 네트워크 명령(fetch/pull/push) 금지.

### 8.3 alien 감지 후 운영 절차 (사람 처리)

1. 봇은 즉시 작업 중단, 회장/아누에게 보고
2. 회장이 evidence JSON 검토:
   - alien commit sha 확인
   - 책임 task 식별
   - 영향 범위 평가
3. 회장 결정에 따라 (택 1):
   - **a. 분리:** 별도 worktree 에서 사람이 수동 cherry-pick / rebase
   - **b. 폐기:** task 브랜치 폐기 후 새 worktree 에서 재작업
   - **c. 수용:** 의도된 cross-task 작업이라면 수용 (특수 케이스, 회장 승인 필수)
4. 처리 완료 후 회장이 `.frozen` 마커 수동 삭제
5. taskctl_verify 재실행하여 PASS 확인

### 8.4 정책 위반 시

본 spec 의 자동 복구 금지를 어기는 코드(rebase / cherry-pick / LLM 호출 등)가
구현(Phase 2-D) 에 포함되면 **즉시 reject**. 본 정책은 변경 시 회장 직접 승인 + spec
새 버전 발행 필수.

---

## 9. 호출 측 통합 (Phase 2-D 참고)

### 9.1 taskctl_verify.py 가 호출

```python
import subprocess

def check_mixed_commit(task_id: str) -> dict:
    res = subprocess.run(
        ["python3", "scripts/mixed_commit_detector.py", task_id],
        capture_output=True, text=True, timeout=60,
    )
    summary = json.loads(res.stdout) if res.stdout else {}
    return {
        "exit": res.returncode,
        "summary": summary,
        "stderr": res.stderr,
    }
```

verify 의 9번 검사 결과 매핑:

| detector exit | verify 9 결과 |
|---------------|---------------|
| 0             | PASS          |
| 1             | FAIL          |
| 2             | FAIL (안전 측) |

### 9.2 git pre-commit / pre-push hook 가 마커 검사

Phase 2-A 의 hook 에서:

```bash
TASK_ID=$(detect_current_task)  # branch name 등에서 추론
if [ -f "$WORKSPACE_ROOT/.tasks/locks/$TASK_ID.frozen" ]; then
    echo "[BLOCK] mixed commit detector freeze 마커 존재 — 회장/아누 처리 필요"
    cat "$WORKSPACE_ROOT/.tasks/locks/$TASK_ID.frozen"
    exit 1
fi
```

### 9.3 taskctl finish 가 마커 검사

```python
frozen = workspace / ".tasks" / "locks" / f"{task_id}.frozen"
if frozen.exists():
    print(f"[BLOCK] {frozen} 존재 — finish 차단")
    print(frozen.read_text())
    sys.exit(1)
```

### 9.4 마커 제거는 어디에서도 자동화하지 않음

- detector: 제거 없음
- taskctl: 제거 없음
- hooks: 제거 없음
- 봇 (LLM): 제거 금지

회장/아누의 수동 `rm` 만 가능.

---

## 10. 테스트 케이스 (Phase 2-D 구현용 참고)

| #  | 시나리오                                | 기대 exit | 기대 effect                              |
|----|-----------------------------------------|-----------|------------------------------------------|
| M1 | clean (모두 본 task)                     | 0         | freeze 없음, evidence 없음               |
| M2 | empty (origin/main..HEAD 비어있음)       | 0         | 동일                                     |
| M3 | alien 1건                                | 1         | freeze 생성, evidence 저장, stdout JSON  |
| M4 | alien 다중 (한 subject 안에 두 token)    | 1         | 동일, alien_tasks 길이 1                 |
| M5 | untagged 만 있음 (token 부재)            | 0         | freeze 없음, status=`clean_with_untagged` |
| M6 | `--json` 옵션 + alien                    | 1         | freeze/evidence 저장 안함, stdout payload|
| M7 | `--branch` 다른 ref + alien              | 1         | freeze/evidence 저장 (task-id 기준)      |
| M8 | git 명령 실패 (origin/main 없음)         | 2         | internal error 보고                      |
| M9 | freeze 마커 이미 존재 + alien 재발견     | 1         | timestamp 갱신, 마커 유지                 |

---

## 11. 변경 이력

| 버전 | 일자       | 작성자 | 변경                          |
|------|------------|--------|-------------------------------|
| 1.0  | 2026-05-05 | 엔키   | 초안 작성 (task-2459 Phase 2-C) |

---

## 12. 부록 — 알고리즘 요약 카드

```
┌──────────────────────────────────────────────────────────────────┐
│ mixed_commit_detector.py — 5-step algorithm                     │
├──────────────────────────────────────────────────────────────────┤
│ 1. git log origin/main..HEAD --pretty=format:'%H|%s'             │
│ 2. tokens = re.findall(r'\[(task-\w+)\]', subject)               │
│    ★ subject 전체에서 매치, prefix anchor 금지                   │
│ 3. alien = set(all_tokens) - {本_task_id}                        │
│ 4. if alien:                                                     │
│      .tasks/locks/<id>.frozen 생성                               │
│      .tasks/evidence/<id>/mixed-commit-*.json 저장               │
│      exit 1                                                      │
│ 5. else: exit 0                                                  │
├──────────────────────────────────────────────────────────────────┤
│ exit: 0=clean/empty, 1=mixed, 2=internal_error                   │
│ 정책: 자동 복구 금지 (rebase / cherry-pick / LLM 호출 0줄)       │
│ 마커 제거: 회장/아누 수동만 (봇 자체 제거 절대 금지)             │
└──────────────────────────────────────────────────────────────────┘
```
