docs(design): transport 추상화 + 트리거 3종 + opt-in 원칙 확정

- design/transports.md — DiscordTransport (v1) / GatewayHttpTransport (v2) / MockTransport
  Rails invoke payload를 HTML 주석으로 감싸 디스코드 전송 (자기야는 자연어만 봄)
- design/triggers.md — opt-in 3종:
  1. /rails start 슬래시 커맨드
  2. 하랑이 제안 → 사용자 승인
  3. Gitea webhook → 자동 배포 진입
- OVERVIEW.md — scope에 opt-in 원칙 + 일상대화 Out of Scope 명시 + 문서 링크 9개로 확장

결정: 자기야 승인 — 2026-04-10 확인
- DiscordTransport 를 v1 기본 transport 로 채택
- 자매 자연어 포스팅은 그대로 유지 (가시성)
- gateway HTTP API 는 v2 에서 스펙 확인 후 전환

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 13:04:43 +09:00
parent 28a03f8bb4
commit 565a2960e2
3 changed files with 457 additions and 3 deletions

View File

@@ -18,23 +18,36 @@
## 범위 (Scope)
### 핵심 원칙: Rails 는 **opt-in**
Rails 는 4자매의 일상 대화를 가로채지 않는다. **명시적 트리거** 에서만 활성화된다. 트리거는 3종류:
1. Slash command (`/rails start ...`)
2. 하랑이 제안 후 사용자 승인
3. Gitea webhook (자동 배포)
상세: [`design/triggers.md`](./design/triggers.md)
### In Scope
- 4자매 역할: 하랑(Planner) / 나랑(Generator) / 다랑(Evaluator/QA) / 이랑(Infra/Deploy)
- XState 기반 결정론적 오케스트레이터
- Sprint Contract (Zod schema + validator)
- Skill 강제 진입 (hook 기반 bypass 차단)
- Skill 강제 진입 (hook 기반 bypass 차단, project 모드 한정)
- 상태 전이 기반 자매 핸드오프
- SisterTransport 추상화 (DiscordTransport v1, GatewayHttpTransport 후속)
- 재시도 / 타임아웃 / 에스컬레이션
- QA 체크리스트 runtime
- 디스코드 알림 (사용자 알림 전용)
- 디스코드 이중 채널: 자매 자연어 대화 (가시성) + rails payload 마커 (제어) + rails 상태 마커 (조감도)
- Gitea webhook receiver (자동 배포 진입)
- SQLite 기반 상태 영속화
- 기존 hanarang-harness 자산 마이그레이션 (scaffold, install, agents md)
### Out of Scope
- **4자매 일상 대화** — 기존 방식 그대로 유지. Rails 는 관여하지 않음
- OpenClaw 런타임 자체의 수정
- 자매 모델 자체 학습 / 파인튜닝
- 디스코드 봇 기능 확장 (파이프라인 외 기능)
- GitHub 연동 (Gitea 전용)
- 멘션 기반 자매 간 핸드오프 (구 하네스 경로. 제거 대상 — SPRINT-007)
## 성공 기준 (Definition of Done)
@@ -108,7 +121,14 @@
## 관련 문서
- [`failure-audit.md`](./failure-audit.md) — F1F6 실패 감사
- [`design/`](./design/) — 설계 문서 세트
- [`design/state-machine.md`](./design/state-machine.md) — XState FSM 설계
- [`design/sprint-contract.md`](./design/sprint-contract.md) — Sprint Contract + Zod validator
- [`design/skill-enforcement.md`](./design/skill-enforcement.md) — 4계층 bypass 차단
- [`design/handoff.md`](./design/handoff.md) — 자매 간 상태 전이 핸드오프
- [`design/retry-policy.md`](./design/retry-policy.md) — 재시도 / 에스컬레이션
- [`design/qa-template.md`](./design/qa-template.md) — QA 체크리스트 runtime
- [`design/transports.md`](./design/transports.md) — SisterTransport 추상화 (Discord / HTTP / Mock)
- [`design/triggers.md`](./design/triggers.md) — opt-in 트리거 3종 (slash / 하랑 제안 / gitea webhook)
- [`sprints/`](./sprints/) — 스프린트 명세
- [`migration/from-hanarang-harness.md`](./migration/from-hanarang-harness.md) — 마이그레이션

214
.plans/design/transports.md Normal file
View File

@@ -0,0 +1,214 @@
# Design — Sister Transport 추상화
> **처방 대상**: F4 (핸드오프 불안정) — 결정성 확보
> **관찰 요구사항**: 자매 간 대화가 사용자에게 보여야 함 (자기야 피드백)
> **현실 제약**: 기존 OpenClaw gateway HTTP API 스펙이 문서화돼 있지 않음
## 문제
Rails orchestrator 는 4자매를 결정론적으로 호출해야 하지만:
1. **기존 hanarang-harness 는 HTTP gateway 를 안 쓴다** — 전부 `openclaw message send --channel discord` 경유
2. **OpenClaw gateway API 스펙이 공개 안 됨**`/health`, `/api/channels` 만 확인됨. 나머지는 reverse-engineering 필요
3. **자기야는 자매 간 대화를 시각적으로 보고 싶어함** — 완전 HTTP 로 숨기면 UX 손상
## 해결: SisterTransport 인터페이스
Rails FSM 과 "어떻게 자매를 깨우는가" 를 분리. 전송 구현을 교체 가능하게 설계.
```ts
interface SisterTransport {
// 자매에게 구조화 task 전송, 응답 대기 (timeout 포함)
invoke(req: InvokeRequest): Promise<HandoffMessage>
// 자매의 연결 상태 체크
health(sister: SisterName): Promise<HealthStatus>
// (옵션) 자매가 중간 진행상황을 stream 할 때 구독
subscribe?(sister: SisterName, callback: (progress: Progress) => void): Unsubscribe
}
```
## 구현 1 — DiscordTransport (v1 기본)
디스코드 채널을 **메시지 큐** 로 사용. 자매 자연어 대화는 그대로 유지, rails 가 보내는 task 는 별도 마커 블록으로 감쌈.
### 전송 (rails → 자매)
Rails 가 자매 전용 채널(또는 공용 파이프라인 스레드)에 메시지 post:
```
<!-- rails:invoke v1 -->
```json
{
"pipelineId": "01HW0XYZ",
"contractId": "01HW0ABC",
"role": "generator",
"sprintId": "SPRINT-001",
"task": {
"title": "scaffold: Next.js + styled-components",
"workdir": "/home/narang/projects/arang",
"dodSummary": "...",
"environmentPrerequisites": ["node22", "pnpm"]
},
"replyTo": { "channelId": "1234", "messageId": "5678" },
"timeoutMs": 30000,
"structuredOutput": true
}
```
```
<!-- /rails:invoke -->
나랑아, 이 작업 맡아줘. contract 는 `.rails/contracts/01HW0ABC.json`.
```
- **기계 부분 (HTML 주석 안)** — 자매 봇이 LLM 우회해서 직접 파싱
- **자연어 부분 (주석 밖)** — 자기야가 보는 용 + 자매가 맥락 이해용 참조
디스코드는 HTML 주석을 렌더링하지 않으므로 **자기야는 자연어만 보임**.
### 수신 (자매 → rails)
자매가 task 완료 후 같은 채널(또는 replyTo 지정 채널)에 post:
```
<!-- rails:result v1 -->
```json
{
"pipelineId": "01HW0XYZ",
"actor": "narang",
"verdict": "IMPL_DONE",
"payload": {
"branch": "feature/sprint-001",
"commits": ["8fc6032"],
"workdir": "/home/narang/projects/arang",
"selfTestReport": {
"typecheck": "pass",
"build": "pass",
"runtimeSmoke": "pass"
}
}
}
```
```
<!-- /rails:result -->
구현 완료했어요! branch: `feature/sprint-001`, tests 전부 통과했어.
다랑이한테 넘길 준비 끝났습니다 ❤️
```
Rails 는 Discord 봇으로 들어가 있어 `MESSAGE_CREATE` 이벤트를 구독. 해당 pipelineId 의 result 가 들어오면 FSM 에 이벤트 전달.
### 자매 봇 변경사항 (최소 침습)
자매 OpenClaw 쪽에 **메시지 디스패처** 를 추가:
```pseudo
onDiscordMessage(msg):
if msg.content contains '<!-- rails:invoke v1 -->':
payload = extractJsonBlock(msg.content, 'rails:invoke v1')
if payload.structuredOutput == true:
# LLM 우회 경로: 결정론 파이프라인 모드
result = processRailsInvoke(payload)
postRailsResult(payload.replyTo, result)
return # LLM 한테 안 넘김
# 그 외는 기존 자연어 대화 경로
llmRespond(msg)
```
- `rails:invoke` 마커가 없으면 **기존 자유 대화 그대로**
- 마커가 있으면 structured task 로 처리 → 자매 봇이 자기 내부 task runner 호출 → 결과를 marker 로 리턴
- 중간에 자연어 메시지 postprocess 는 옵션 (자매가 "시작했어요" 같이 맥락 메시지 추가 가능)
이 변경이 SPRINT-007 마이그레이션의 핵심.
### 타임아웃 / 에스컬레이션
- Rails 가 invoke 후 `timeoutMs` 내 result 이벤트 못 받으면 TIMEOUT
- Discord API 전송 실패는 네트워크 에러로 분류 → retry policy
- result 의 Zod 검증 실패는 ERROR → retry policy
## 구현 2 — GatewayHttpTransport (후속 / v2)
OpenClaw gateway HTTP API 스펙 파악 후 구현. 현재는 **placeholder**.
### 조사 필요 사항
- [ ] 가용한 엔드포인트 전수 (웹 UI 소스 + network tab 캡쳐)
- [ ] 인증 방식 (Bearer? API key?)
- [ ] agent invoke API 존재 여부
- [ ] streaming / long-poll 지원
- [ ] rate limit
### 잠재 장점 (스펙 파악되면)
- 디스코드 rate limit 무관
- 지연 감소
- 중계 hop 감소 (Discord → 자매봇 → task runner 경로 단축)
- 완전 로컬 테스트 가능 (디스코드 없이도 시뮬레이션)
### 구현 조건
- Gateway API 가 agent invoke 를 지원하거나
- Rails 팀이 `/api/rails/invoke` 같은 커스텀 엔드포인트를 gateway plugin 으로 추가
- 후자가 더 안정적 (우리 통제)
## 구현 3 — MockTransport (테스트용)
테스트 / 로컬 개발용. 자매 없이 rails 자체를 시험 가능.
```ts
class MockTransport implements SisterTransport {
async invoke(req: InvokeRequest): Promise<HandoffMessage> {
// 사전 정의된 시나리오 응답
return MOCK_SCENARIOS[req.role][req.sprintId] ?? defaultMock(req)
}
async health() { return { alive: true, latencyMs: 1 } }
}
```
- `rails run --mock <project>` 서브커맨드로 mock 모드 강제
- Sprint 001 의 end-to-end 테스트는 mock transport 로 돌림
## 설정 (config)
`.rails/config.yaml`:
```yaml
transport:
default: discord
discord:
botToken: ${DISCORD_TOKEN}
guildId: ${DISCORD_GUILD_ID}
pipelineCategory: 1234567890
sisterChannels:
harang: 1111
narang: 2222
darang: 3333
erang: 4444
# 나중에
# gateway:
# url: https://gw.hanarang.local
# token: ${GATEWAY_TOKEN}
timeouts:
invokeMs: 30000
discordApiMs: 10000
```
환경 변수 `RAILS_TRANSPORT=mock` 으로 override 가능 (테스트용).
## 자매 대화 가시성 (요약)
| 요소 | 어디 | 누가 포스팅 | 자기야가 봄 |
|---|---|---|---|
| 자매 자연어 메시지 | Discord 파이프라인 스레드 | 자매 본인 봇 | ✅ |
| Rails 전송 payload | 같은 메시지의 `<!-- -->` 주석 | Rails 봇 또는 invoker | ❌ (자동 숨김) |
| 자매 result payload | 같은 메시지의 `<!-- -->` 주석 | 자매 본인 봇 | ❌ |
| Rails 상태 마커 | Rails 봇 | Rails | ✅ (조감도) |
| 에스컬레이션 | Rails 봇 + @멘션 | Rails | ✅ (긴급 알림) |
| 디버깅 상세 | `rails timeline` / `rails inspect` CLI | — | 요청 시 |
| SQLite SoT | 로컬 DB | Rails | 요청 시 (`rails inspect`) |
## 참고
- `principles.md` 원칙 2 (결정론적 FSM)
- `failure-audit.md` F4
- `handoff.md` — HandoffMessage 스키마
- OpenClaw gateway port: 18789 (LAN bind), auth 필요 (스펙 미확인)

220
.plans/design/triggers.md Normal file
View File

@@ -0,0 +1,220 @@
# Design — Rails 트리거 모드
> **Rails 는 opt-in**. 4자매의 일상 대화를 가로채지 않는다.
> Rails 가 "켜지는" 경로는 명시적으로 3가지 뿐.
## 원칙
- **Casual 대화 vs Project 모드의 경계가 명확해야 한다.**
- Casual: 자매 자유 대화. Rails 개입 0. SQLite 기록 없음.
- Project: Rails FSM 이 주도. sprint contract + enforcement 적용.
- **자동 가로채기 금지.** 사용자 의도 없이 project 모드로 전이할 수 없다.
- **하랑이는 제안만 할 수 있다.** 실제 시작은 사용자 승인 필요.
## 트리거 모드 3종
### 트리거 1 — Slash Command (가장 명시적)
Rails Discord 봇이 등록한 슬래시 커맨드:
```
/rails start <project> "<요구사항>"
/rails start arang "Sprint 002 — Live2D 아바타 추가"
/rails status [pipeline-id]
/rails resume <pipeline-id>
/rails abort <pipeline-id>
/rails inspect <pipeline-id>
/rails timeline <pipeline-id>
```
**흐름**:
1. 사용자가 디스코드에서 `/rails start arang "..."` 실행
2. Rails 봇이 파이프라인 ID 생성 + Discord 포럼 스레드 생성
3. 하랑이 호출 (DiscordTransport invoke)
4. FSM 이 `idle → planning` 전이
5. 이후 파이프라인 끝까지 자동 진행
**장점**:
- 가장 명확. opt-in 이 100% 확실
- Rails 봇이 권한 체크 가능 (자기야만 실행 허용 등)
- 파라미터 검증 즉시
### 트리거 2 — 하랑이의 제안 (자연스러움)
평소 채팅 중 프로젝트 스케일 요청이 감지되면 **하랑이가 제안만** 함. 자동 실행 금지.
```
자기야: "아랑이 proactive 엔진 진짜 필요할 것 같아. 한 번 만들어볼래?"
하랑이 🦊: 이거 Sprint 단위 작업 같은데, rails 파이프라인으로 넘길까?
요구사항: "Arang Proactive 엔진 프로토타입"
예상 타입: feature
예상 스프린트: 2~3개 분해 예정
승인하면 다음 커맨드를 실행해줘:
/rails start arang "Proactive 엔진 프로토타입"
(또는 "ㅇㅇ" 이라고 답하면 내가 propose 파일 만들어두고
자기야가 확인만 하면 실행되도록 할게)
```
**흐름**:
1. 하랑이 시스템 프롬프트에 "project-scale 감지 시 rails 제안" 룰 내장
2. 감지 조건 (예시):
- "프로젝트", "기획", "구현", "배포", "Sprint", "만들어줘" 등 키워드
- 다단계 작업 필요 판단
- 여러 자매 협업 필요 판단
3. 하랑이가 **제안 메시지** 포스팅 (실행 아님)
4. 사용자가:
- 옵션 A: 슬래시 커맨드 직접 실행
- 옵션 B: "ㅇㅇ" / "응" 같이 짧게 동의
5. 옵션 B 인 경우, 하랑이는 `.rails/proposals/` 에 proposal 파일 생성하고, rails 봇이 이를 감지해 `proposal` 상태로 등록
6. 사용자가 `/rails approve <proposal-id>` 또는 제안 메시지의 확인 버튼을 누르면 실제 `rails start` 실행
**자동 실행 안 하는 이유**:
- 하랑이 LLM 이 잘못 감지할 수 있음
- 사용자 의도 확인이 반드시 필요
- `principles.md` 원칙 4 (강제 > 권고)
**하랑이 제안 → 승인 트랜잭션**:
```
proposal 상태 (.rails/proposals/<id>.json):
{
"id": "01HW0PR0P",
"proposedBy": "harang",
"at": "2026-04-10T13:00:00Z",
"project": "arang",
"title": "Proactive 엔진 프로토타입",
"rationale": "다단계 작업 + proactive 감지 + 설정 UI 필요",
"status": "pending",
"expiresAt": "2026-04-10T14:00:00Z" // 1시간 후 자동 만료
}
사용자 승인:
→ status: "approved"
→ rails start 트리거
→ pipelineId 생성 후 status: "consumed"
```
### 트리거 3 — Gitea Webhook (자동 배포 진입)
특정 이벤트 발생 시 rails 가 **배포 단계부터** 자동 진입. 기획/구현은 안 함.
#### 지원 이벤트
| Gitea 이벤트 | Rails 액션 | 진입 상태 |
|---|---|---|
| `push` to main with tag (`v*`) | 이랑이 deploy actor 자동 호출 | `deploying` |
| `push` to main without tag | 이랑이 `smoke-check` actor | `verifying` |
| `pull_request` merged to main | 다랑이 post-merge review | `post-review` |
| `release created` | 이랑이 배포 수행 | `deploying` |
#### 흐름 (tag push 예시)
```
1. 개발자가 push tag v0.2.0 to hanarang/arang
2. Gitea webhook → POST https://rails.hanarang.local/webhook/gitea
3. Rails webhook receiver:
- Gitea signature 검증 (HMAC)
- 이벤트 타입 + 저장소 + ref 확인
4. Rails 가 새 파이프라인 생성 (`type: deploy-only`)
5. FSM 을 `idle → deploying` 상태로 직접 전이
6. 이랑이(erang) actor 호출 — project type 감지 → 배포 수행
7. Discord 파이프라인 스레드 생성 + 진행 알림
8. 배포 완료 후 `done` 전이
9. 사용자에게 배포 결과 알림 (디스코드 멘션)
```
#### 보안
- Webhook URL 은 Gitea 내부망에서만 접근 가능 (vmbr1)
- HMAC 서명 검증 필수
- IP 화이트리스트 (Gitea 서버 IP 만)
- secret 은 `.env``GITEA_WEBHOOK_SECRET`
- 실패 시 즉시 로그 + 알람
#### 저장소별 구성
`.rails/webhooks.yaml`:
```yaml
repositories:
- org: hanarang
repo: arang
events: ['push', 'tag', 'release']
branches: ['main']
pipelineType: deploy-only
deployTarget: production
- org: hanarang
repo: hanarang-rails
events: ['tag']
pipelineType: deploy-only
deployTarget: self-install
```
## 비트리거 (안 함)
Rails 는 다음 상황에서 **자동 개입하지 않는다**:
- 자매 간 일상 대화
- 자기야와의 일상 대화 (잡담, 질문, 상담)
- Discord 의 일반 메시지 (rails 마커 없는)
- 멘션 (`@나랑이` 등) — 기존 자매 봇 경로로만 작동
- 단순 파일 편집 (git commit 등)
- CI 실행 결과 (별도 CI 시스템 이미 있으면 그쪽 우선)
## 모드 구분 가드 (자매 봇 쪽)
자매가 메시지를 받으면:
```pseudo
onDiscordMessage(msg):
# Project 모드 (Rails invoke)
if msg.content has '<!-- rails:invoke v1 -->':
return handleRailsInvoke(msg)
# Casual 모드 (기존 대화)
llmRespond(msg)
```
- 마커 없으면 무조건 Casual. LLM 정상 응답.
- 마커 있으면 Project. structured 처리.
- 한 메시지 안에 두 모드 섞을 수 없음 (동시 발생 X).
## 제안 메시지의 최소 스펙 (하랑이 쪽)
하랑이가 Project 감지 시 생성하는 제안 메시지 템플릿:
```markdown
**📋 Rails 파이프라인 제안**
감지된 project-scale 작업:
- 프로젝트: `{project}`
- 제목: {title}
- 예상 타입: `{feature|bugfix|scaffold|infra|migration|refactor}`
- 근거: {rationale}
실행하려면:
```
/rails start {project} "{title}"
```
또는 "ㅇㅇ" / "응" 이라고 답하면 proposal 파일을 만들어둘게.
<!-- rails:proposal v1 -->
```json
{
"proposalId": "01HW0PR0P",
"project": "{project}",
"title": "{title}",
"type": "{type}",
"rationale": "{rationale}"
}
```
<!-- /rails:proposal -->
```
**주의**: 마커는 rails 봇이 감지. 자기야는 주석 없이 자연어 메시지만 봄.
## 관련 문서
- `transports.md` — DiscordTransport (구조화 payload 전달)
- `handoff.md` — HandoffMessage 스키마
- `skill-enforcement.md` — Rails 모드 진입 후 hook 동작
- `principles.md` — 원칙 1, 4