<!-- task=m-platformless-git-operation-engine plan=0 tag=SCAN -->

# Plan - SCAN

## 이 파일을 읽는 구현 에이전트에게

`CODE_REVIEW-local-G06.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이 필수 마지막 단계다. 구현 후 검증 명령을 실행하고, active 파일은 그대로 둔 채 리뷰 준비 상태를 보고한다. 종결 판정, 로그 아카이브, `complete.log` 작성, task directory archive 이동은 code-review 스킬 전용이다. 구현 중 사용자 결정, 사용자 소유 외부 환경, scope conflict 없이는 안전하게 진행할 수 없으면 review stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 기록하고 멈춘다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하거나 `USER_REVIEW.md`, `complete.log`, archive 로그를 만들지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 증거 공백은 사용자 리뷰 요청이 아니다.

## 배경

`scan` Epic에서 `changed-files`는 작은 보강으로 직접 처리되어 local commit range integration test가 추가되었다. 남은 작업은 저장된 repo/branch cursor를 기준으로 현재 Git revision을 읽고, 변경이 있으면 `core.RevisionEvent` 후보를 만드는 흐름이다. 이 작업은 storage cursor, gitengine diff, controlplane revision handling을 연결하므로 plan-code-review 루프로 진행한다.

## 사용자 리뷰 요청 흐름

구현 중 차단 사유는 active `CODE_REVIEW-local-G06.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 이 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`에서 복사된 형식이며, 구현 중 direct user prompt는 금지된다. code-review가 차단 요청을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/control-plane-foundation/milestones/platformless-git-operation-engine.md`
- Task ids:
  - `cursor`: repo/branch별 last seen revision cursor를 저장한다.
  - `event-input`: scan 결과를 `RevisionEvent` 후보로 변환한다.
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/private/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-ops/rules/project/domain/core/rules.md`
- `agent-ops/rules/project/domain/workspace-ops/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/core-smoke.md`
- `agent-test/local/workspace-ops-smoke.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/control-plane-foundation/PHASE.md`
- `agent-roadmap/phase/control-plane-foundation/milestones/platformless-git-operation-engine.md`
- `agent-task/archive/2026/06/m-platformless-git-operation-engine/complete.log`
- `services/core/internal/core/model.go`
- `services/core/internal/gitengine/command.go`
- `services/core/internal/gitengine/command_test.go`
- `services/core/internal/controlplane/runtime.go`
- `services/core/internal/controlplane/runtime_test.go`
- `services/core/internal/storage/storage.go`
- `services/core/internal/storage/postgres.go`

### 테스트 환경 규칙

- `test_env`: `local`
- `agent-test/local/rules.md`: 존재하며 읽음.
- 읽은 profile:
  - `agent-test/local/core-smoke.md`: core 변경은 최소 `cd services/core && go test ./...`로 확인한다. gitengine/storage/controlplane behavior 변경은 해당 동작을 검증하는 테스트가 필요하다.
  - `agent-test/local/workspace-ops-smoke.md`: task artifact와 roadmap 변경은 `git diff --check`로 확인한다.
- 적용 명령:
  - focused: `cd services/core && go test -count=1 ./internal/controlplane -run TestRuntimeScanBranchRevision`
  - focused support: `cd services/core && go test -count=1 ./internal/gitengine -run TestChangedFilesWithStatusLocalCommits`
  - core smoke: `cd services/core && go test ./...`
  - workspace preflight: `git diff --check`
- `<확인 필요>` 값: 없음.
- fallback verification: 없음.

### 테스트 커버리지 공백

- `cursor`: `storage.RevisionCursorStore`와 Postgres 구현은 존재하고, `Runtime.HandleRevision`이 외부 revision event 처리 중 cursor를 저장한다. 그러나 scan entrypoint가 cursor를 읽어 current revision과 비교하고 next cursor를 저장하는 테스트는 없다.
- `changed-files`: 직접 처리 완료. `TestChangedFilesWithStatusLocalCommits`가 여러 commit 사이 changed file name/status를 local git repo로 검증한다.
- `event-input`: `core.RevisionEvent` 모델과 Forgejo webhook normalization은 존재하지만, platformless scan 결과를 `RevisionEvent{RepoID, Branch, Before, After, ChangedFiles, ObservedAt}`로 만드는 테스트가 없다.

### 심볼 참조

- renamed/removed symbol: none.
- relevant symbols:
  - `services/core/internal/gitengine/command.go:172` `HeadRevision`
  - `services/core/internal/gitengine/command.go:208` `ChangedFilesWithStatus`
  - `services/core/internal/core/model.go:63` `RevisionEvent`
  - `services/core/internal/core/model.go:94` `RevisionCursor`
  - `services/core/internal/storage/storage.go:75` `RevisionCursorStore`
  - `services/core/internal/controlplane/runtime.go:147` `HandleRevision`

### 분할 판단

split policy를 plan file 선택 전에 평가했다. 남은 `cursor`와 `event-input`은 cursor의 before revision을 읽고 동일한 scan 결과를 `RevisionEvent`로 만드는 한 controlplane 흐름이다. API/foundation과 broad rollout이 분리되지 않고, 같은 `services/core/internal/controlplane` 테스트로 검증할 수 있으며, 별도 subtask로 나누면 첫 작업이 사용되지 않는 중간 API만 남긴다. 따라서 단일 plan으로 진행한다.

### 범위 결정 근거

- 포함: `services/core/internal/controlplane/runtime.go`, `services/core/internal/controlplane/runtime_test.go`, 필요한 경우 `services/core/internal/gitengine/command.go`의 작은 helper 사용 또는 호출.
- 제외: provider webhook normalization, REST route 추가, Postgres migration, contracts 문서, operation DTO 확장, command timeout/stdout/stderr structured result. 이 plan은 scan Epic의 `cursor`와 `event-input` 완료에 한정한다.
- `changed-files`는 이미 직접 처리되어 roadmap에 `[x]`로 반영했으므로 이 plan의 Roadmap Targets에서 제외한다.

### 빌드 등급

- build lane/grade: `local-G06`
- rationale: storage cursor, gitengine revision/diff, controlplane runtime을 연결하지만 외부 API나 migration 없이 deterministic Go tests로 검증 가능한 bounded core 내부 작업이다.

## 구현 체크리스트

- [ ] cursor-backed scan entrypoint를 추가해 first observation, unchanged revision, changed revision의 cursor 저장 동작을 검증한다.
- [ ] scan diff 결과를 `core.RevisionEvent` 후보로 변환하고 changed file status가 보존되는지 검증한다.
- [ ] `cd services/core && go test -count=1 ./internal/controlplane -run TestRuntimeScanBranchRevision`, `cd services/core && go test -count=1 ./internal/gitengine -run TestChangedFilesWithStatusLocalCommits`, `cd services/core && go test ./...`, `git diff --check`를 실행하고 결과를 review stub에 붙인다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [SCAN-1] Cursor-Backed Revision Scan

문제: `services/core/internal/storage/storage.go:75`의 `RevisionCursorStore`는 repo/branch cursor를 저장할 수 있고, `services/core/internal/controlplane/runtime.go:220`은 외부 revision event 처리 뒤 cursor를 upsert한다. 하지만 scan entrypoint가 `GetRevisionCursor`로 before revision을 읽고 현재 worktree revision과 비교한 뒤 first observation/unchanged/changed 상태를 결정하는 경로가 없다.

해결 방법: `services/core/internal/controlplane/runtime.go`에 scan용 options와 method를 추가한다. method는 repoID, branch, workdir을 trim/validate하고 runner가 비어 있으면 `gitengine.CLI{}`를 사용한다. 현재 revision은 `gitengine.HeadRevision`으로 읽는다. cursor가 없으면 현재 revision을 `RevisionCursor`로 저장하고 event 없이 `matched=false`를 반환한다. cursor revision과 현재 revision이 같으면 observed time만 갱신하고 event 없이 `matched=false`를 반환한다. cursor revision이 다르면 `SCAN-2`의 event 후보를 만든 뒤 기존 `HandleRevision(ctx, provider, "", revision)`에 넘겨 branch watch/dedupe/broadcast/cursor 저장 규칙을 재사용한다.

Before:
```go
// services/core/internal/controlplane/runtime.go:147
func (r *Runtime) HandleRevision(ctx context.Context, provider, deliveryID string, revision core.RevisionEvent) (EventRecord, bool, error) {
```

After:
```go
type ScanRevisionOptions struct {
	Provider   string
	RepoID     string
	Branch     string
	Workdir    string
	Runner     gitengine.CommandRunner
	ObservedAt time.Time
}

func (r *Runtime) ScanBranchRevision(ctx context.Context, opts ScanRevisionOptions) (EventRecord, bool, error) {
	// validate store/cursor support, read after revision, compare cursor, and call HandleRevision only on changes.
}
```

수정 파일 및 체크리스트:
- [ ] `services/core/internal/controlplane/runtime.go`: `gitengine` import를 추가한다.
- [ ] `services/core/internal/controlplane/runtime.go`: `ScanRevisionOptions`와 `ScanBranchRevision`을 추가한다.
- [ ] `services/core/internal/controlplane/runtime_test.go`: first observation이 cursor만 저장하고 event를 만들지 않는 테스트를 추가한다.
- [ ] `services/core/internal/controlplane/runtime_test.go`: unchanged revision이 event를 만들지 않는 테스트를 추가한다.

테스트 작성: 작성한다. Test name은 `TestRuntimeScanBranchRevisionCursorStates`; assertion은 first observation cursor 저장, unchanged no event, changed scan 전 cursor before 값 사용이다. fixture는 temp local git repo와 `fakeStore`다.

중간 검증:
```bash
cd services/core && go test -count=1 ./internal/controlplane -run TestRuntimeScanBranchRevisionCursorStates
```
기대 결과: `ok git.toki-labs.com/toki/gito/services/core/internal/controlplane`.

### [SCAN-2] RevisionEvent Candidate From Scan

문제: `services/core/internal/core/model.go:63`의 `RevisionEvent`는 scan 결과를 담을 수 있고, `services/core/internal/gitengine/command.go:208`의 `ChangedFilesWithStatus`는 file status를 반환한다. 하지만 scan 결과를 `core.RevisionEvent`로 변환하는 경로와 검증이 없다.

해결 방법: `ScanBranchRevision`의 changed path에서 `gitengine.ChangedFilesWithStatus(runner, workdir, before, after)`를 호출하고, `[]gitengine.ChangedFile`을 `[]core.ChangedFile`로 변환해 `core.RevisionEvent`를 만든다. `ObservedAt`은 options 값이 있으면 사용하고, 없으면 `time.Now().UTC()`를 사용한다. event는 `HandleRevision`으로 전달해 기존 watch matching과 branch.updated envelope behavior를 유지한다.

Before:
```go
// services/core/internal/gitengine/command.go:208
func ChangedFilesWithStatus(runner CommandRunner, workdir, before, after string) ([]ChangedFile, error) {
```

After:
```go
func revisionEventFromScan(repoID, branch, before, after string, files []gitengine.ChangedFile, observedAt time.Time) core.RevisionEvent {
	// copy path/change_type into core.ChangedFile and preserve before/after.
}
```

수정 파일 및 체크리스트:
- [ ] `services/core/internal/controlplane/runtime.go`: scan file status를 `core.ChangedFile`로 변환하는 unexported helper를 추가한다.
- [ ] `services/core/internal/controlplane/runtime_test.go`: changed scan이 `RevisionEvent.Before`, `After`, `ChangedFiles`, cursor after revision을 모두 검증하게 한다.
- [ ] `services/core/internal/controlplane/runtime_test.go`: branch watch가 없는 scan change는 event를 emit하지 않는지 확인한다.

테스트 작성: 작성한다. Test name은 `TestRuntimeScanBranchRevisionBuildsRevisionEvent`; assertion은 local git commit range에서 modified/added file status가 `record.Revision.ChangedFiles`에 보존되고 cursor가 after revision으로 갱신되는지 확인한다.

중간 검증:
```bash
cd services/core && go test -count=1 ./internal/controlplane -run TestRuntimeScanBranchRevisionBuildsRevisionEvent
```
기대 결과: `ok git.toki-labs.com/toki/gito/services/core/internal/controlplane`.

## 수정 파일 요약

| 파일 | 항목 |
| --- | --- |
| `services/core/internal/controlplane/runtime.go` | SCAN-1, SCAN-2 |
| `services/core/internal/controlplane/runtime_test.go` | SCAN-1, SCAN-2 |

## 최종 검증

```bash
cd services/core && go test -count=1 ./internal/controlplane -run TestRuntimeScanBranchRevision
```

```bash
cd services/core && go test -count=1 ./internal/gitengine -run TestChangedFilesWithStatusLocalCommits
```

```bash
cd services/core && go test ./...
```

```bash
git diff --check
```

기대 결과: 모든 명령이 exit code 0으로 종료한다. `go test ./...`는 Go cache output을 허용한다. focused controlplane/gitengine 명령은 fresh execution으로 scan/cursor behavior와 changed-files evidence를 검증한다.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
