42 Commits

Author SHA1 Message Date
c58bc311a6 feat(notify): junior 가 LLM 응답에 직접 discord 한 줄 멘트 emit (옵션 C)
자기야 결정: 옵션 C — junior 가 자기 work 끝에 ```discord-line``` 블록을
출력하고, sister-agent 가 그걸 추출해서 stage-end Discord notify 메시지로
사용. 추가 LLM 호출 0, 매번 다른 메시지, 자매 본인이 자기가 한 일을 직접
보고하는 느낌.

## 변경

### prompts.ts
- buildPrompt 의 footer 에 role==='junior' 분기 추가
- 새 helper discordLineFooter(ctx): 자매별 페르소나 + stage 별 예시 포함
- 출력 형식 강제: ```discord-line\n<한 줄>\n```
- 50 자 이내, 이모지 1-2 개, 실패시 ✗/⚠️/🛑 명시 지시

### discord-notify.ts
- StageMessageContext.customLine 필드 추가
- extractDiscordLines(text): 모든 ```discord-line``` 블록 추출 +
  본문에서 strip → { lines, cleaned }
- renderStageEnd 가 customLine 우선, 없으면 hardcoded 풀로 fallback

### spawn.ts
- aggregated 만들기 전에 extractDiscordLines() 호출
- cleaned 텍스트만 buildSuccessResult / priorStages 에 전달 (chat noise
  가 다음 stage 의 LLM context 로 새지 않게)
- 첫 번째 추출된 line 을 customLine 으로 renderStageEnd 에 전달

## 효과

이전: 다랑이가 항상 "🔍 리뷰 통과! 이랑이 받아" 같은 4 variant pool 에서
픽 → 식상 + 작업 내용 무관

이후: 다랑이가 직접 LLM 응답 끝에 emit
  - "🔍 체크리스트 다 ✓. addTodo/toggleTodo/deleteTodo 모두 동작. 이랑이 받아."
  - "⚠️ addTodo 가 빈 입력 처리 못 함. 나랑아 다시 봐줘."
LLM 이 자기가 본 코드의 실제 결함을 한 줄로 요약. 같은 stage 라도 매번
다른 메시지. fallback 은 그대로 작동.
2026-04-11 16:24:56 +09:00
ece52f9dd5 fix(notify): stage-end Discord 메시지가 실제 verdict 와 일치하게
자기야 발견: 다랑이가 디스코드에 "리뷰 통과! 이랑이 받아" 라고 말했는데
실제로는 REQUEST_CHANGES 가 발사돼서 나랑이로 다시 돌아감. 페르소나 메시지
와 실제 흐름이 어긋나는 버그.

원인:
spawn.ts 의 흐름이
  1. manager LLM 호출
  2. children LLM 호출
  3. **Discord stage-end notify 발사**  ← 이 시점엔 verdict 모름
  4. buildSuccessResult() → 여기서 verdict 결정

renderStageEnd 가 verdict 를 안 받아서 항상 "통과" 풀에서 픽함. forced
가드 (RAILS_FORCE_REVIEW_VERDICT) 검증 시에도, 정상 LLM 이 REQUEST_CHANGES
를 내놓을 때도 동일하게 잘못된 메시지가 나감.

수정:
- discord-notify.ts:
  - StageMessageContext 에 verdict 필드 추가
  - END_POOLS 를 verdict 별로 분리:
    * harang/ok, harang/abort
    * narang/ok, narang/error
    * darang/approve, darang/request_changes, darang/abort
    * erang/ok, erang/failed
  - endPoolKey() 함수가 (agentName, verdict) → 풀 키 결정
  - renderStageEnd 가 verdict 를 받아 적절한 풀에서 픽

- spawn.ts:
  - notifyDiscord stage-end 호출을 buildSuccessResult() **이후** 로 이동
  - result.verdict 를 renderStageEnd 에 전달
  - notify 결과도 명시 로깅 (notify.end + verdict)

이제 다랑이는 verdict 에 따라 "리뷰 통과! 이랑이 받아" / "결함 발견 — 나랑아
다시 봐줄래?" / "이건 접근 자체가 잘못된 것 같아. 중단." 중 하나로 답함.
이랑이는 "배포 검증 완료" / "배포 실패 — 자기야 봐줘" 둘 중 하나.
2026-04-11 16:12:55 +09:00
fb90abc8e3 fix(runner): review-loop 소진으로 escalated 도달 시 recordEscalation/notify 누락
자기야 검증: forced REQUEST_CHANGES 로 escalation 까지 갔는데 디스코드 알림이
안 떨어졌음. 로그 분석 결과 recordEscalation() 호출 자체가 0회.

원인: runner.ts 의 main loop 가 recordEscalation 을 ERROR 이벤트의
non-retryable 분기에서만 호출. 그런데 review-loop 소진 + replan 소진으로
FSM 이 자체적으로 escalated 로 전이한 경우는 ERROR 가 아니라 REQUEST_CHANGES
이벤트 경로라서 그 분기를 안 탐. 결과: escalation row 안 만들어지고 notifier
도 안 호출됨.

수정:
- escalationRecorded flag 추가, ERROR non-retryable 분기에서 true 로 세팅
- lastActiveStage 를 매 iteration 마다 트래킹
- main loop 종료 후 result.state === "escalated" && !escalationRecorded 면
  post-loop 에서 recordEscalation() + emit({type:"escalated"}) 호출
- stage 는 lastActiveStage (review-loop 소진의 경우 보통 "review"),
  attempts 는 context.replanCount, reason 은 context.lastError

검증 예정: 다음 forced 테스트에서 디스코드 채널에 escalation SOS 알림 떨어짐.
113 테스트 그대로 통과.
2026-04-11 15:35:08 +09:00
fc646894d9 feat: review DoD 체크리스트 + 자매 페르소나 톤 + 에스컬레이션 디스코드 알림
자기야 묶음 요청 (1+2+3):
  1) 에스컬레이션 발생 시 디스코드 SOS 알림
  2) review prompt 를 DoD 체크리스트로 강제
  3) 디스코드 stage 알림 톤 자매별 페르소나로 다양화

## #2: review DoD 체크리스트화

prompts.ts 의 reviewHintForRole 강화:
- manager: 자유 prose 금지, 정해진 출력 형식 강제
    ## DoD 체크리스트
    - [✓|✗] <항목> — <근거>
    ## 최종 결정
    APPROVE | REQUEST_CHANGES | ABORT
    ## 결정 근거
    <한 문단>
  - "✓" 가 아닌 "통과/OK" 같은 단어 금지 (parser 가 못 잡음)
  - minor/스타일 결함은 REQUEST_CHANGES 사유로 카운트 안 함
  - 사용자가 의도한 모순 (e.g. "함수 비워줘") 은 새 DoD 로 인식
- principal: [critical|major] <어디> — <무엇> — <왜> — <고침> 형식
- lead: ✓/✗ <기능명>: 동작 OK/실패 — <근거> 형식
- junior: 동일 verdict 시작 + 한 문단

## #3: 디스코드 페르소나 톤

discord-notify.ts:
- START_POOLS / END_POOLS 를 자매별 4 variant 풀로 확장
  - 하랑 차분/단정, 나랑 활달, 다랑 꼼꼼, 이랑 차분/믿음직
- 안정적 픽: pipeline title 해시로 결정 → 같은 task 같은 line, 다른 task
  로테이션 → robotic 느낌 제거
- 새 함수 renderEscalation(): SOS 메시지 포맷 (멘션 + 사유 + 대시보드 링크)

## #1: 에스컬레이션 디스코드 알림

흐름:
  rails orchestrator 가 escalated 시점 도달
  → recordEscalation() 의 EscalationNotifier 인터페이스 호출
  → DiscordEscalationNotifier 구현체가 sister-agent (하랑이) 의 /notify
    엔드포인트에 POST
  → sister-agent 가 local openclaw CLI 로 디스코드 채널에 메시지 발송
  → 자기야 멘션 + 사유 + pipelineId + 복구 명령

신설:
- src/handoff/escalation-notifier.ts: DiscordEscalationNotifier 클래스
- sister-agent/src/server.ts: POST /notify 엔드포인트 (channelId + message)
- src/server/http.ts ServerOpts 에 escalationConfig 옵션 추가. 시작
  엔드포인트가 notifyChannelId 를 받으면 per-pipeline notifier 인스턴스
  생성 → runPipeline 의 notifier opt 로 주입
- src/cli/serve.ts: RAILS_NOTIFY_SISTER_URL + RAILS_NOTIFY_USER_ID 환경
  변수 읽어 startHttpServer 에 escalationConfig 전달

운영 시 활성화: Dev VM 의 .env 에 다음 두 줄 추가
  RAILS_NOTIFY_SISTER_URL=http://10.10.10.112:18801
  RAILS_NOTIFY_USER_ID=452664876691881984

113 테스트 그대로 통과.
2026-04-11 05:11:53 +09:00
185320f1b9 fix(fsm): retry/replan budget 축소 + 테스트 검증 가드 추가
자기야 발견: maxReplans=2, maxReviewRounds=3 budget 으로 forced REQUEST_CHANGES
검증을 돌렸는데, FSM 자체는 정상 작동했지만 (replanCount=1 정확히 카운트, plan→
review×3→implement loop 정확히 발동) 총 12 review attempts × LLM 호출 30-60s =
실 운영에서 escalation 까지 15-30분 걸림. 사용자가 "무한 루프" 라고 느낄 정도.

수정:
- src/orchestrator/context.ts:
  maxReviewRounds: 3 → 2
  maxReplans: 2 → 1
  → 총 budget = (1+1)*(1+2) = 6 review attempts (이전 12 의 절반)
  → 실 운영 wall-clock 약 3-6 분 안에 escalation 도달
- src/orchestrator/machine.ts: 동일하게 default context 갱신
- tests/machine.test.ts: 새 budget 에 맞춰 round 수 조정
  - "after max review rounds" 테스트: 4 round → 3 round
  - "escalates after both budgets exhausted" 테스트: 12 round → 6 round
- sister-agent/src/spawn.ts:
  RAILS_FORCE_REVIEW_VERDICT 환경변수 가드 추가 (test-only).
  APPROVE / REQUEST_CHANGES / ABORT 중 하나 설정하면 LLM 우회하고 즉시 verdict
  반환. darang sister 에 박아서 retry FSM E2E 검증 가능.
  운영 시점엔 env 미설정 → no-op, LLM 응답 정상 사용.

테스트: 113 통과 (변경 없음).

검증 흔적: pipeline 01KNWB8WYRR11PGY8DYMNQ1BZD 가 forced REQUEST_CHANGES 로
이전 budget (12 round) 의 60% 까지 진행 후 수동 abort. transitions 18 개에
replanCount=1, reviewRound=3 정확히 보존됨 — persistence/FSM 모두 정상.
2026-04-11 04:12:42 +09:00
6c6a0a50dc fix(prompt): plan/implement manager hint 도 가짜 팀 narration 금지
자기야 발견: 다랑이/이랑이 (review/deploy) 의 manager 가 가짜 팀 분배를
출력하던 건 이전 커밋에서 고쳤는데, 하랑이/나랑이 (plan/implement) 의
manager 도 똑같이 "수석 1명은... 선임 2명은... 신입 2명은..." 를 풀어서
narration 하고 있었음. 분해는 sister-agent 의 planner.ts 가 complexity
score 로 결정론적으로 결정하지 LLM 의 prose 가 결정하지 않는데, manager
LLM 이 prose 로 가짜 team plan 을 출력해서:

  - 토큰 낭비 (실제로 spawn 에 영향 0)
  - 사용자 혼란 ("진짜 그렇게 팀이 구성됐나?" 오해)
  - 다음 stage 의 priorStages 노이즈 증가

수정 — plan/implement manager hint 를 stage 별로 짧게 재작성:

plan/manager:
  1) MVP 범위 한 줄
  2) 명시적 비범위 한 줄
  3) 통과 기준 한 줄
  명령형/단정형 강제. "수석/선임/신입" 단어 사용 금지.

implement/manager:
  1) 기술 스택 / 런타임 한 줄
  2) 파일 구조 1~2 줄
  3) 핵심 구현 결정 한 줄
  같은 제약.

추가로 principal/lead/junior 의 plan/implement hint 에도 "팀 narration 금지"
한 줄을 박아 일관성 유지. junior 의 implement code 출력에도 코드 블록 안에
가짜 팀 prose 를 섞지 말라는 가드 추가.

실제 분해는 코드 (planner.ts) 가 한다는 사실을 prompt 자체에 명시.
2026-04-11 02:54:02 +09:00
6c43cccca5 fix(prompt): review/deploy stage 의 role hint 를 stage-aware 하게 분리
자기야 발견: 다랑이의 review 답변이 "수석 1명은 ...전체 원문 다시 제출,
선임 1명은 CRUD 재정리, 신입 A는 ..., 신입 B는 ..., 나는 마지막에 승인"
형태로 나옴 — 즉 verdict 가 아니라 가상 팀 분배 plan 을 출력.

원인: prompts.ts 의 roleOutputHint 가 stage 와 무관하게 동일했음.
- manager hint: "이 작업을 어떻게 분해할지, 어떤 팀(수석/선임/신입) 을 배치할지"
- principal hint: "구체적으로 어떤 리스크... 어떻게 분해되어야 하는지"
- lead hint: "신입에게 어떻게 나눠줄지, 검증 포인트"

이게 plan/implement 단계에서는 맞는데 review/deploy 에서는 작동 모델이
완전히 다름. 다랑이 manager 가 review 단계에서 "팀 분배" 지시를 받으니
review verdict 대신 가짜 팀 plan 을 내놓고 있었음. 이랑이 deploy 도 동일.

수정: stage-specific role hint 분리.

reviewHintForRole(role):
- manager: 절대 분해/분배 금지. 첫 줄 APPROVE/REQUEST_CHANGES/ABORT, 다음
  bullet 1~3개 근거. "내가 마지막에 본다" 같은 미래 약속 금지.
- principal: critical 결함 1~3개. 형식: [심각도] 어디 — 무엇 — 왜 — 어떻게 고쳐야
- lead: 사용자 요구사항의 각 핵심 기능별 통과 여부 한 줄. ✓ <기능>: ... 또는 ✗
- junior: 첫 줄 verdict, 한 문단 이유. 코드 다시 짜지 마.

deployHintForRole(role):
- manager: 종합 한 문단 + 마지막 줄 DEPLOY_DONE/DEPLOY_FAILED.
- principal: 배포 환경 리스크 (브라우저/CSP/CDN/의존성) 1~3개 또는 "리스크 없음".
- lead: "□ <확인 절차>" 체크리스트.
- junior: 기존 그대로.

plan/implement 의 hint 는 그대로 보존 — 거기선 분해가 맞으니까.
2026-04-11 02:29:36 +09:00
fe9ec0d9db fix(prompt): priorStages text 가 prompt 에 박힐 때 2500자로 잘리던 마지막 잘림 지점
자기야 발견: 다랑이가 또 'editInput = document.createElement(...)' 에서 잘렸
다고 함. narang junior 의 LLM 출력 원본 (junior-01-*.md) 은 5KB 로 끝까지
정상이고, spawn.ts 의 aggregated/summary 도 64KB 로 OK 였는데, 실제 다랑이
LLM 에 박히는 prompt 텍스트 단계에서 또 잘리고 있었음.

원인: prompts.ts 의 buildPrompt 가 priorStages text 를 prompt 에 박을 때
slice(0, 2500) 로 자르고 있었음. 5KB 짜리 HTML 의 절반 가까이에서 정확히
잘림. prevStageOutput slice(0, 2000) 도 같은 패턴.

수정:
- priorStages text slice: 2500 → 64_000 (upstream 과 일치)
- prevStageOutput slice: 2000 → 32_000
- prompt 헤더에 "잘리지 않은 원본" 명시 추가 — LLM 이 "이게 잘렸나?" 의심
  하지 않게 (다랑이가 잘렸다고 오해할 가능성도 같이 줄임)

LLM context window (200k tokens+) 안에서 안전. priorStages 가 4 stage
누적되어도 256KB 정도면 50k tokens 미만.
2026-04-11 02:21:52 +09:00
294abdbc25 feat(fsm): re-planning loop — review 다 실패하면 plan 단계로 되돌아감
자기야 요청: 안쪽 review-loop 다 써도 그냥 escalation 이 아니라, 한 단계
위에서 plan 부터 다시 짜야 함. 첫 plan 자체가 잘못된 접근일 수도 있으니까.

새 FSM:
  reviewing → REQUEST_CHANGES
    ├─ canReviewAgain (reviewRound < maxReviewRounds) → implementing
    ├─ canReplan (replanCount < maxReplans)           → planning
    │     • incrementReplanCount, resetReviewRound
    │     • lastError = "Re-planning after exhausted review rounds"
    │     • 다음 plan 단계가 priorStages 로 review issues 를 보고 새 접근
    └─ both exhausted                                  → escalated

기본값: maxReplans=2 → 총 budget = (1+2)*(1+3) = 12 round (3 plans × 4 reviews
each). 그 이상 가면 사용자 개입.

추가:
- src/orchestrator/context.ts: replanCount, maxReplans 필드 (default 0, 2)
- src/orchestrator/machine.ts: canReplan guard, incrementReplanCount action,
  reviewing.REQUEST_CHANGES 의 transition 분기
- tests/machine.test.ts: 기존 "escalates after max review rounds" 를 새
  re-plan 동작에 맞게 수정 + 두 개 새 케이스 추가
    1. maxReplans+maxReviewRounds 둘 다 소진 후 escalated
    2. re-plan 후 새 plan 으로 APPROVE → done 흐름

113 테스트 모두 통과 (105 → 113).
2026-04-11 01:47:40 +09:00
ae2d4b1d3e fix(truncation): priorStages slice 한도가 너무 짧아 review 가 잘린 코드 받던 버그
자기야 발견: 다랑이 review 결과에 "코드가 끊긴다" 가 반복 등장. 다랑이의
실제 review 메시지를 확인해 보니 narang 이 만든 todo-debug-mode.html 의
<script> 가 `const form = document.getElementById(` 에서 잘려서 반복 round
마다 같은 잘림 지점을 지적했음.

원인 추적:
- narang junior 의 LLM 출력 원본 (junior-01-*.md) 은 깔끔하게 </html> 로
  종료. LLM 자체는 멀쩡.
- spawn.ts buildSuccessResult 의 summary slice(0, 2000) 가 1차로 자름
- 그 위 aggregated slice(0, 6000) 가 0차로 자름
- runner extractStageText 의 review issues slice(0, 2000) 도 추가 한도
- LLM-emit reason 도 spawn.ts parseReviewVerdict 에서 1000/800 자로 잘림

todo HTML 한 파일이 5KB 정도 되니 6000 자 한도에서 3분의 1 잘려나감 →
darang 은 결과적으로 절반짜리 코드를 받음 → 영원히 REQUEST_CHANGES.

수정 (모두 LLM context 200k+ 안에서 안전한 한도):
- spawn.ts aggregated: 6_000 → 64_000
- spawn.ts buildSuccessResult.summary: 2_000 → 64_000
- spawn.ts parseReviewVerdict reason: 1_000 → 16_000
- spawn.ts parseDeployVerdict reason: 800/1000 → 16_000
- runner.ts extractStageText review issues: 2_000 → 32_000

검증: 다음 실행에서 darang 이 동일한 잘림 지점을 지적하지 않으면 OK.
2026-04-11 01:16:49 +09:00
6627ad709f fix(verdict): review/deploy stage 가 LLM 출력을 무시하고 verdict 하드코딩하던 버그
자기야 발견: 다랑이가 코드에 문제가 있다고 답해도 파이프라인이 그대로 deploy
로 넘어가는 현상. 원인은 sister-agent 의 buildSuccessResult 가 review/deploy
stage 의 verdict 를 LLM 출력과 무관하게 "APPROVE" / "DEPLOY_DONE" 로 하드
코딩하고 있었음. FSM 의 review-loop 와 escalation 경로는 정상이었지만
sister 가 한 번도 REQUEST_CHANGES 를 emit 하지 않아 loop 가 죽어 있었음.

수정:
- parseReviewVerdict(text): LLM 출력에서 ABORT / REQUEST_CHANGES / APPROVE
  키워드 탐색. 마커가 없으면 conservative 하게 REQUEST_CHANGES 로 분류해
  silent approval 방지.
- parseDeployVerdict(text): DEPLOY_FAILED / DEPLOY_DONE 마지막 마커 추출.
  마커 없으면 DEPLOY_FAILED 로 bias.
- buildSuccessResult: review/deploy 케이스가 위 파서 결과를 사용. issues
  배열에 LLM reason 을 major severity 로 첨부 → runner 가 같은 텍스트를
  다음 implement round 에 priorStages 로 전달함.

검증: pipeline 01KNW1MJWGMZHXDE7178SP8FZB
  planning → implementing → reviewing
    → REQUEST_CHANGES (round 1) → implementing
    → reviewing → REQUEST_CHANGES (round 2) → implementing
    → reviewing → REQUEST_CHANGES (round 3) → implementing
    → reviewing → REQUEST_CHANGES (max exceeded) → escalated
  context: reviewRound=3, lastError="Max review rounds exceeded"
2026-04-11 01:07:43 +09:00
13b2c00048 fix(notify): openclaw CLI cold-start 7-10s 대비해 timeout 25s + 디버그 로그
발견: 첫 E2E 테스트에서 4 자매 중 하랑이만 notify 성공, 나머지 3 자매는
"openclaw timeout" 으로 실패. 직접 측정해 보니 narang LXC 의
`openclaw message send` 가 7.258s (gateway connect + auth cold start).
기존 8s timeout 이 빡빡해서 가끔 들어오고 가끔 죽었음.

수정:
- discord-notify.ts: timeoutMs 기본값 8000 → 25000ms. fire-and-forget
  호출이라 메인 파이프라인 latency 영향 없음.
- spawn.ts: notify.start 호출 결과를 명시적 로그로 남김 (성공/실패/error
  각각 가시화). 다음 디버깅을 빠르게.
- server.ts: invoke.start 로그에 notifyChannelId 표시.

검증: 파이프라인 01KNW0ADA5CBFBVGCRV7VN1YTF 에서 4 자매 모두 notify.start
ok:true 확인.
2026-04-11 00:37:36 +09:00
809d5b94c4 feat(notify): 각 자매가 본인 봇 identity 로 디스코드 stage 업데이트 직접 포스트
자기야 요청: 지금은 하랑이가 모든 stage 를 대신 말해서 어색함. 각 자매가
자기 작업할 때 본인 목소리로 짧게 디스코드에 보고해야 함.

핵심 발견: OpenClaw 가 sessions.json (`agent:main:discord:channel:<id>`) 의
키에 활성 채널 ID 를 박아 놓음. updatedAt 으로 정렬하면 가장 최근에 자기야
가 말한 채널을 자동 추출 가능. bash tool 환경변수에는 채널 ID 가 안 들어
있어서 이 우회가 필요했음.

## rails 쪽 (notifyChannelId 전파)

- handoff/message.ts: InvokeRequest schema 에 notifyChannelId optional 추가
- src/orchestrator/runner.ts: RunOptions 에 notifyChannelId 받아 InvokeRequest
  에 그대로 propagate
- src/server/http.ts: StartRequest schema + /pipelines/start 와
  /pipelines/start-async 둘 다 notifyChannelId 받아 runPipeline 에 전달
- sister-agent/src/types.ts: InvokeRequest 에도 같은 필드 추가

## sister-agent 쪽 (각자 본인 봇으로 포스트)

- sister-agent/src/discord-notify.ts: 신설. local openclaw CLI 를 spawn 으로
  호출해 본인 봇 identity 로 메시지 발송 (best-effort, 실패해도 파이프라인
  안 막음). 자매별 페르소나 메시지 템플릿 (renderStageStart/End) 포함
- sister-agent/src/spawn.ts: executeInvocation 시작과 manager 완료 시점에
  notifyDiscord 호출. notifyChannelId 가 없으면 no-op

## skill wrapper

- ~/.openclaw/skills/hanarang-rails/scripts/rails-start-and-watch.sh:
  sessions.json 에서 가장 최근 discord 채널 ID 자동 추출 →
  /pipelines/start-async 본문에 notifyChannelId 포함. 중간 stage echo 제거 —
  이제 각 자매가 본인 봇으로 직접 포스트하므로 하랑이는 시작 banner 와 최종
  보고만 출력
- SKILL.md "스크립트 출력 처리" 섹션 새 흐름에 맞게 업데이트

## 검증

- 사전 검증: nara LXC 에서 `openclaw message send --channel discord --target
  channel:<id>` 호출이 정상 작동 (Message ID 받아옴), 자기야가 디스코드에서
  나랑이 봇 메시지 확인
- E2E 테스트: 다음 단계에서 실제 디스코드 호출로 최종 검증
2026-04-11 00:02:29 +09:00
4a77f43a32 fix(sister-agent): trivial tier ghost pipeline + openclaw model override 거부
## Bug 1: trivial tier 가 산출물 없는 유령 파이프라인 생성

planner.ts 의 trivial 케이스가 strategy="direct" + spawn=[] 였음. 그런데
spawn.ts 의 maybeExtractFiles() 는 role !== "junior" 면 파일을 저장하지
않고, manager 의 프롬프트는 "본인이 직접 코드를 짜지 않는다" 로 박혀 있어,
trivial 태스크는 아무도 코드를 안 쓰는 상태로 done 처리됨.

수정: trivial tier 도 single-junior 로 강제. junior 1 명이 무조건 코드를
생성하게 함. 이전 "direct" 전략은 의도적으로 사용 안 함.

재현: "간단한 todo 앱 만들어" → score 10 → tier=trivial → ghost pipeline
검증: smoke-todo-v3 파이프라인이 implement/files/frontend/sprints/SPRINT-AUTO/
      index.html (115 줄) 을 실제로 생성함

## Bug 2: openclaw 어댑터가 모델 override 로 OpenClaw 거부됨

LLM 어댑터 리팩터 시 spawn.ts 에서 callLlm 에 model: ROLES[role].primaryModel
을 명시하게 했는데, 그 모델 이름 (gpt-5.4 / glm-5-turbo 등) 이 OpenClaw
agent="main" 의 allowlist 에 없어서 "Model override not allowed" 로 거부됨.

수정: openclaw 어댑터는 --model 플래그를 더 이상 넘기지 않음. OpenClaw 의
자체 라우팅에 모델 선택을 위임. 다른 어댑터 (openai/anthropic/ollama) 는
그대로 req.model 을 honor 함.

검증: smoke-todo-v3 의 narang junior 가 LLM 호출 성공, 실제 HTML 생성
2026-04-10 23:41:12 +09:00
e4f8e6ef53 revert(bridge): discord.js 봇 전면 제거 — OpenClaw 가 봇 소유자
이전 커밋 08ea92f 은 잘못된 접근이었음. OpenClaw 가 이미 Discord 게이트웨이를
띄우고 있고 자매들의 봇 identity 는 거기 하나로 통일되어야 함. rails 가
discord.js 로 별도 봇을 등록하면 두 봇이 같은 채널에 공존하는 기이한 구조가
된다.

올바른 경로는 OpenClaw skill 의 user-invocable frontmatter 로 슬래시 커맨드를
노출하는 것이고, 이건 별도 커밋으로 ~/.openclaw/skills/hanarang-rails/ 에
반영됨.

삭제:
- src/bridge/ 전체 (discord-client, discord-commands, discord-notifier, index)
- tests/discord-notifier.test.ts
- package.json 의 discord.js 의존성
- src/cli/serve.ts 의 bridge 부트스트랩
- .env.example 의 DISCORD_* 블록

보존:
- runner.ts 의 PipelineLifecycleEvent + onEvent 훅 — 유닛 테스트/대시보드
  WebSocket 등에 재사용 가능
- POST /pipelines/start-async 엔드포인트 — skill 의 polling wrapper 가 이걸 씀
- runner opts.pipelineId 지원 — async start 가 의존함
- http.ts ServerOpts 의 onPipelineEvent/notifier 파라미터 — 추상화는 유지,
  serve.ts 가 주입을 안 할 뿐

테스트: 118 → 111 (discord-notifier 7 개 삭제), 나머지 그대로 통과.
2026-04-10 22:35:24 +09:00
08ea92f540 feat(bridge): Discord 봇 — 슬래시 커맨드 트리거 + 실시간 알림
v0.1.4 — 옵션 3 (outbound + inbound). DISCORD_TOKEN 이 설정되지 않으면
bridge 는 no-op 이라 기존 배포는 영향 없음.

## Outbound (rails → Discord)

- runner.ts: PipelineLifecycleEvent emitter 추가
    started / stage-done / stage-failed / completed / failed / escalated
- DiscordNotifier: 이벤트 → Discord 메시지 렌더링
    thread mode (슬래시 커맨드 트리거) vs channel mode (CLI/HTTP 트리거)
- EscalationNotifier 인터페이스도 구현 — escalate.ts 에서 사용자에게 알림
- runPipeline opts 에 onEvent + notifier 주입

## Inbound (Discord → rails)

- /rails start project:<name> requirements:<text> — 파이프라인 기동
    → defer reply → POST /pipelines/start-async → thread 생성 → 실시간 업데이트
- /rails status <id> — 상태 조회 (ephemeral)
- /rails abort <id> — 강제 종료 (ephemeral)

## Async start 엔드포인트

- POST /pipelines/start-async: pipelineId 즉시 리턴 후 background 에서
  runPipeline 실행. Discord 의 3초 ACK 타임아웃을 회피.
- runPipeline 에 opts.pipelineId 지원: async 엔드포인트가 미리 만든
  row 위에 파이프라인을 그대로 얹을 수 있게.

## 부트스트랩

- rails serve 가 DISCORD_TOKEN/GUILD_ID/NOTIFY_CHANNEL_ID 세 개가 모두
  있으면 DiscordBridge 를 자동 시작. 없으면 "skip" 로그 남기고 무시.
- discord.js ^14.26 의존성 추가.

## 문서/테스트

- .env.example: Discord 섹션 전면 재작성 (동작 설명 포함)
- tests/discord-notifier.test.ts (7 tests): fake DiscordClientWrapper 로
  라우팅/렌더링/바인딩 해제 로직 검증
- 총 111 → 118 테스트 통과
2026-04-10 22:13:26 +09:00
8c123cb03a feat(v0.1.3): LLM 어댑터 + in-process 모드 + docker-compose — 외부인 배포 친화
## LLM 공급자 어댑터 (B)

- sister-agent/src/llm/ 에 LlmAdapter 인터페이스 신설. 어댑터 5종:
    openclaw (기존), openai, anthropic, ollama, mock
- 선택은 LLM_PROVIDER 환경변수로. 기본값 mock.
- 모델명은 LLM_MODEL_{MANAGER,PRINCIPAL,LEAD,JUNIOR} 로 외부화.
  OpenClaw 내부 네이밍이 기본값이지만 env 로 얼마든지 갈아끼움.
- openai 어댑터는 OPENAI_BASE_URL 로 OpenRouter / Azure / 로컬 llama.cpp
  서버까지 커버.

## In-process 단일 프로세스 모드 (C)

- sister-agent/src/core.ts 로 executeInvocation 을 library-export
- rails 에 InProcessTransport 추가. 동적 import 로 sister-agent core 를
  로드해 같은 Node 프로세스에서 함수 호출로 실행.
- DirectRailsClient 로 HTTP 루프백 없이 DB 에 직접 쓰기 — 단일
  프로세스에서도 observability 동일.
- RailsConfig 에 transport: "in-process" 추가.
- buildTransports 가 RAILS_TRANSPORT 와 per-stage override 를 지원하도록
  확장. 레거시 RAILS_TRANSPORT_MODE + RAILS_AGENT_*_HOST 도 그대로 호환.

## Git push 외부화 + allowlist env 화

- spawn.ts 의 ENABLE_GIT_PUSH 를 "GITEA_TOKEN 있으면 auto on" 으로 변경.
  기존 Dev 토폴로지는 sister LXC 들에 이미 토큰이 있어서 행동 변화 없음.
- rails.service.ts 의 파일 프록시 allowlist 를 GIT_RAW_ALLOWED_HOSTS
  env 로 외부화. 기본값은 기존 Gitea 호스트 유지.

## Docker / 배포

- Dockerfile 추가. 단일 이미지로 rails + sister-agent 둘 다 빌드.
- docker-compose.yml (기본): mariadb + rails 한 컨테이너 = in-process.
  docker compose up 한 줄로 로컬 E2E 가능.
- docker-compose.full.yml: rails + 4 개 독립 sister 컨테이너 = 분산.

## 설정 샘플 + 문서

- .env.example 완전 재작성: 필수/LLM/토폴로지/Gitea/Discord 5 섹션
- rails.config.local.yaml: in-process 샘플
- rails.config.distributed.yaml: http 분산 샘플
- docs/LOCAL-SETUP.md: 30분 퀵스타트 (Docker + 네이티브 두 경로)
- README 에 "5분 퀵스타트" + 토폴로지 표 + LLM 공급자 목록 추가

## 테스트

- tests/transport-build.test.ts (6 테스트): 기본값 / in-process /
  per-stage override / http 엔드포인트 누락 / env 기반 wiring / 레거시
  env 호환
- 전체 테스트 105 → 111 통과
2026-04-10 21:51:53 +09:00
c2e89ec5da docs: 완전 가이드 추가 — 처음 보는 사람용 전 구간 해설
- docs/GUIDE.md: 20 장 + 3 부록, 배경/원칙/아키텍처/데이터 모델/FSM/계층/
  sister-agent/LLM/파일 추출/Git push/대시보드/E2E 시나리오/API/Contract/
  보안/복원력/설치/디렉토리/로드맵/용어집 망라
- docs/GUIDE.pdf: weasyprint 로 렌더, 목차 + 페이지 헤더/풋터 포함
- README.md: 가이드 링크 추가, 상태 섹션 v0.1.2 까지 갱신, 아키텍처 그림
  과 설계 원칙 정리
2026-04-10 21:21:02 +09:00
be8715a7f0 fix(pipeline): track produced file paths accurately for deploy URL
- RunContext.producedFiles[] accumulates repo-relative paths of extracted
  code files as juniors write them
- implement selfTestReport now includes producedFiles[]
- runner extractStageText forwards producedFiles= in priorStages
- derivePreviewUrl uses producedFiles list first (exact match), then falls
  back to rawUrlBase/index.html heuristic
- Fixes bug where deployUrl pointed to files/index.html but actual repo
  path was implement/files/frontend/sprints/SPRINT-AUTO/index.html
2026-04-10 20:48:37 +09:00
17b2f2b232 feat(pipeline): E+F+G — file artifacts, git push, deploy URL
E - Dashboard drawer 산출물 섹션
- completed event payload 의 file / extractedFiles / deployUrl / repoUrl 추출
- 파일 배지 + 언어별 색상 (html/js/ts/css/json/md/url)
- URL 은 클릭 가능한 링크

F - Gitea auto-commit + push (narang/implement stage)
- sister-agent/src/git-ops.ts — ensureGiteaRepo + commitAndPush
  Gitea API POST /api/v1/orgs/{org}/repos 로 repo 자동 생성
  git init + add + commit + push (HTTPS + token in URL)
  repo 이름: rails-<last-10-of-pipeline-id>
  사용자: rails-agent <rails@hanarang.local>
- spawn.ts: implement stage 종료 시점에 commitAndPush 호출
  gitResult.repoUrl/rawUrlBase/commit/filesCount 를 manager completed event 에 포함
- buildSuccessResult: implement HandoffMessage.selfTestReport 에 git 메타 포함

G - Deploy stage preview URL
- runner.ts extractStageText: implement 단계에서 rawUrlBase/repoUrl/filesCount 파싱해서 priorStages text 에 포함
- spawn.ts derivePreviewUrl(): priorStages 의 implement 텍스트에서 rawUrlBase 추출 + HTML 파일 경로 힌트 조합
- deploy manager completed event 에 deployUrl 포함
- 결국 dashboard drawer 에 클릭 가능한 preview URL 이 뜸

Env 설정:
- sister-agent/.env 에 GITEA_TOKEN, GITEA_BASE_URL, GITEA_ORG, GIT_USER_*
- start-sister-agent.sh 가 .env 를 source
- 4자매 LXC 전부 배포 (700 퍼미션)
2026-04-10 20:35:04 +09:00
1c25fb3b5b feat(pipeline): C stage-chaining + D code block extraction
C - Stage 간 결과 전달:
- InvokeRequest schema + sister-agent types: priorStages[] field
- runner.ts: accumulates stage outputs as pipeline progresses
  extractStageText() pulls summary from each HandoffMessage
  each subsequent invoke gets priorStages[{stage, text}, ...]
- prompts.ts: priorStages rendered as "# 앞 단계(들)의 결과물" section
  manager/principal/lead/junior 모두 볼 수 있음
- spawn.ts: forwards ctx.req.priorStages into doWork() at every level

이제 하랑이 plan 결과가 나랑이 implement 의 프롬프트에 포함되고,
나랑이 결과가 다랑이 review 에, 다랑이 결과가 이랑이 deploy 에 전달됨.
실제 파이프라인으로 이어짐.

D - 코드 블록 추출 + 파일 저장:
- sister-agent/src/code-extractor.ts — 마크다운 코드 블록 파서
  form 지원: ```lang / ```lang:path / ```lang path=... / ```src/file.ext
  path sanitize (.., leading /, absolute 차단)
  LANG_TO_EXT 25+ 매핑
- spawn.ts: maybeExtractFiles() — implement stage junior 에만 적용
  {workspaceDir}/files/{relpath} 로 저장
  sub_task_events.completed.extractedFiles 에 path 목록 포함
- prompts.ts: implement junior 프롬프트에 파일 경로 명시 형식 강제
  "```html:src/index.html" 예시 포함

이제 하랑이가 계획한 내용 기반으로 나랑이가 실제로 코드 파일을 LXC 파일시스템에 저장함.
2026-04-10 20:16:47 +09:00
6a8599e0b3 feat(sister-agent): parallelize + write output files
Parallelization:
- 3중 nested for loop → Promise.all 재귀 tree walker
- 같은 레벨 sub-task 들 전부 동시 실행
- complex tier 15 LLM calls 가 직렬 → tree depth 기반 wall-clock
  manager(1) + max(principals) + max(leads per principal) + max(juniors per lead)
  ≈ 4 calls worth instead of 15

File output:
- SISTER_WORKSPACE_DIR (기본 ~/rails-projects)
- 각 노드의 LLM 출력을 {pipeline}/{stage}/{role}-{idx}-{id}.md 로 저장
- Front matter 에 pipeline/stage/agent/role/subTaskId/createdAt
- file 경로를 sub_task_events 의 completed payload 에 포함

Refactor:
- 402 → 382 lines
- 3중 loop → 재귀 runSpawnNode() 1개 함수
- executeInvocation → runPlanChildren → runSpawnNode (깔끔한 레이어)
2026-04-10 19:56:24 +09:00
a7d5a2bdec fix(config): default agent timeoutMs 30s→600s for LLM workloads 2026-04-10 18:49:03 +09:00
9f238f570d merge: real LLM execution for sister-agent 2026-04-10 18:46:37 +09:00
98540af98c fix(runner): default invoke timeout 30s→600s, retries 3→1 for LLM workloads 2026-04-10 18:46:30 +09:00
579137e4bf feat(rails): GET /api/transitions + /api/escalations for SIEM dashboard 2026-04-10 18:22:58 +09:00
9aeef223c6 feat(rails): GET /api/sub-tasks/:id — node detail with parents/events 2026-04-10 17:54:55 +09:00
e2d71ca47d fix(server): remove deprecated mock-only check in /pipelines/start
transport builder 가 이미 env + config 기반으로 mock/http 결정하므로
서버 레이어의 mock==false 거부는 불필요. 제거.
2026-04-10 17:17:42 +09:00
3e354d21c1 merge: Stage 1+2 — hierarchical sub-agent team 2026-04-10 17:11:23 +09:00
a88323716d feat(stage-1-2): hierarchical sub-agent team — sister-agent + SubTask tracking
Stage 1 + 2 통합 구현 — 사용자 피드백 반영:
  manager / principal / lead / junior 계층 구조 추가.
  부장이 복잡도를 판단해서 팀을 동적으로 꾸린다.

Design:
- .plans/design/hierarchy.md — 전체 설계 문서 (점수/tier/plan/escalation)

Prisma schema:
- SubTask (계층 트리 + 역할/모델/state/complexity)
- SubTaskEvent (JSONL 스타일 이벤트 로그)

rails (core):
- src/hierarchy/roles.ts — 4역할 기본 config + 모델 매핑
  manager/principal: gpt-5.4 / glm-5.1
  lead: gpt-codex-5.3 / glm-5
  junior: glm-5-turbo / gpt-5
- src/hierarchy/complexity.ts — 규칙 기반 스코어러 (7 factors, 0-100)
- src/hierarchy/planner.ts — tier → DecompositionPlan (trivial/simple/moderate/complex/massive)
  + concurrency budget 강제
- src/hierarchy/store.ts — Prisma CRUD + tree builder
- src/handoff/http-transport.ts — 실제 HTTP transport (MockTransport 대체)
- src/handoff/build.ts — config + env 기반 transport 빌더
  (env RAILS_TRANSPORT_MODE + RAILS_AGENT_{STAGE}_HOST 오버라이드)
- src/server/http.ts — sub-task 엔드포인트 4개 추가
    POST /api/sub-tasks
    PATCH /api/sub-tasks/:id
    POST /api/sub-tasks/:id/events
    GET /api/pipelines/:id/sub-tasks (tree view)

sister-agent (new sub-project):
- sister-agent/ — 각 LXC 에 배포될 Node.js 데몬
- src/types.ts, roles.ts, complexity.ts, planner.ts
- src/spawn.ts — executeInvocation: 복잡도 점수 → plan → spawn 트리
  simulation mode (현재는 work 를 delay 로 시뮬레이트, LLM 연동은 후속)
- src/rails-client.ts — sub-task / event push
- src/server.ts — POST /invoke 엔드포인트 (port 18801)

LXC 리소스 실측 기반 기본 concurrency:
  104/106/107: 8 concurrent sub-agents
  105 (narang, build 중): 6

검증: tsc --noEmit ✓ | vitest 105/105 ✓ | rails build ✓ | sister-agent build ✓

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:11:16 +09:00
c8d6ceb337 merge: HTTP API for orchestration 2026-04-10 16:10:44 +09:00
1f518c0c54 feat(server): HTTP API for orchestration (/pipelines/*, /health)
rails serve 가 이제 실제 HTTP 서버를 띄움:
- GET  /health
- GET  /pipelines?limit=N
- POST /pipelines        — 파이프라인 생성만
- POST /pipelines/start  — 생성 + E2E 실행 (현재 mock 만)
- GET  /pipelines/:id    — 상태 + 타임라인
- POST /pipelines/:id/abort — 강제 종료

node built-in http 만 사용 (추가 의존성 없음).
Zod 로 요청 바디 검증. Graceful shutdown (SIGINT/SIGTERM).

하랑이(오케스트레이터) 가 이 API 를 찔러 파이프라인을 기동할 수 있음.
2026-04-10 16:10:39 +09:00
3a226ada95 merge: hotfix — FSM snapshot restore (XState v5) 2026-04-10 16:04:48 +09:00
7a8f2c1af0 fix(persist): use XState v5 getPersistedSnapshot for round-trip
기존 { value, context } 수동 스냅샷은 XState v5 의 restoreSnapshot 이
status/children 등을 기대해서 런타임 에러 발생.

actor.getPersistedSnapshot() 를 써서 완전한 snapshot JSON 을 저장/복원.
getPipelineState 는 snapshot.context 에서 PipelineContext 를 추출.

Dev 서버에서 첫 실행 중 발견.
2026-04-10 16:04:41 +09:00
f6c1768c60 docs: Sprint 007 완료 — v0.1.0 전 스프린트 완료 2026-04-10 15:54:44 +09:00
8786efc81c merge: Sprint 007 — Migration + docs + v0.1.0 (#7) 2026-04-10 15:54:14 +09:00
2cadb3e0df feat(sprint-007): 마이그레이션 도구 + 운영 문서 + v0.1.0 릴리즈 준비
Sprint 007 전체 구현 — 마지막 스프린트. 프로젝트 완성:

CLI:
- rails doctor — 환경 헬스체크 (Node/pnpm/git/env/프로젝트 파일)
- rails scaffold [dir] — 신규 프로젝트 .plans/ 구조 생성
- rails migrate from-hanarang-harness <path> — 레거시 아카이브 스캐너
  agents/scripts/workflows 분류 (portable vs deprecated)
  xhigh 참조 경고 등 위험 패턴 감지

Docs (신규 3종):
- docs/migration-guide.md — 레거시 하네스 → rails 단계별 이전 가이드
- docs/operations.md — PM2, health check, 트러블슈팅, DB 유지보수
- docs/discord-setup.md — 봇 생성, DiscordPoster 구현 예시,
  marker 프로토콜 완전 명세

README 대폭 업데이트:
- v0.1.0 상태 선언
- 빠른 시작 가이드
- CLI 13 서브커맨드 목록
- 문서 링크

Tests (4 신규, 105 total pass):
- 마이그레이션 스캐너 (agents/scripts/workflows 감지)
- node_modules/.git 제외
- 빈 아카이브 처리
- scaffold 디렉토리 구조 검증

검증: tsc --noEmit ✓ | vitest 105/105 ✓ | build ✓
       rails doctor → 정상 출력 ✓
       rails --help → 13 subcommands ✓

마감 상태:
- F1~F6 모든 실패 모드 코어에서 해결
- 7 스프린트 완료 (000: 계획, 001~006: 코어, 007: 릴리즈)
- 105 테스트, 19 문서 (.plans/) + 3 운영 문서 (docs/)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:53:54 +09:00
1d37fee3ad docs: Sprint 006 완료 마크 + 마지막 스프린트 007로 갱신 2026-04-10 15:47:35 +09:00
256f334706 merge: Sprint 006 — QA template runtime (#6) 2026-04-10 15:47:15 +09:00
da85b92a6b feat(sprint-006): QA template runtime — 다랑이 체크리스트 실행
Sprint 006 전체 구현 — F3/F2 의 QA 측면 완성:

Schema:
- src/qa/schema.ts — QaTemplate, QaChecklistResult, QaArtifact Zod 스키마
- Zod + DodCheck 재사용

Templates (6종 YAML):
- qa-templates/scaffold-v1.yaml — README/LICENSE/gitignore/lockfile/strict
- qa-templates/feature-v1.yaml — tests/typecheck/no-console/no-any/tests-added
- qa-templates/bugfix-v1.yaml — regression-test/root-cause/no-scope-creep
- qa-templates/refactor-v1.yaml — tests/typecheck/no-behavior-change
- qa-templates/migration-v1.yaml — rollback/dry-run/data-loss/backup (critical)
- qa-templates/infra-v1.yaml — config-validated/secrets/rollback

Core:
- src/qa/template.ts — YAML loader, extends 체인 resolution, 프로젝트별 extras
- src/qa/verdict.ts — verdict 규칙 (critical/major → REQUEST_CHANGES,
  minor/recommendation 만 → APPROVE_WITH_NITS, 절대 REQUEST_CHANGES 안 됨)
- src/qa/runtime.ts — runQaTemplate: Contract check handlers 재사용
  manual 체크는 resolver 주입 가능 (없으면 SKIP 기본값)

CLI:
- rails qa run <type> [-s sprint-id] [-w workdir]
- rails qa show <artifact-id>
- rails qa templates  (목록)

Tests (19 신규, 101 total pass):
- computeVerdict 7가지 시나리오 (APPROVE / APPROVE_WITH_NITS / REQUEST_CHANGES / ABORT)
- minor-only 는 절대 REQUEST_CHANGES 안 된다는 rule 명시 테스트
- Template loader + listTemplates + extends merge
- runtime: file_exists pass/fail + manual resolver 주입 + artifact 저장
- scaffold-v1 실파일 로드 확인

검증: tsc --noEmit ✓ | vitest 101/101 ✓ | build ✓
       rails qa templates → 6개 전부 출력 ✓

사용자 메모리 feedback_qa_thorough.md 준수:
  - 체크 항목 수 제한 없음
  - 각 템플릿이 타입별로 세분화됨

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:46:58 +09:00
83fb627d2f docs: Sprint 005 완료 마크 + 현재 스프린트 006으로 갱신 2026-04-10 15:41:23 +09:00
39d5f26c40 merge: Sprint 005 — Resilience (#5) 2026-04-10 15:41:03 +09:00
83 changed files with 11134 additions and 123 deletions

View File

@@ -1,17 +1,130 @@
# hanarang-rails environment variables
# Copy to .env and fill in values.
# ────────────────────────────────────────────────────────────────
# hanarang-rails — environment configuration
# Copy this file to `.env` and fill in the values you need.
#
# The file is grouped into:
# 1. required (must set to run any pipeline)
# 2. LLM provider (pick one)
# 3. transport / deployment topology
# 4. optional — Gitea push
# 5. optional — Discord bridge
# ────────────────────────────────────────────────────────────────
# ── Database (MariaDB / MySQL) ──
# ==== 1. REQUIRED =====================================================
# MariaDB / MySQL connection string used by Prisma.
# For docker-compose, use:
# mysql://rails:rails@mariadb:3306/hanarang_rails
DATABASE_URL="mysql://rails:CHANGE_ME@localhost:3306/hanarang_rails"
# ── Discord ──
DISCORD_TOKEN=""
DISCORD_GUILD_ID=""
# ── Gitea Webhook ──
GITEA_WEBHOOK_SECRET=""
# ── Rails ──
# Rails HTTP server port
RAILS_PORT=18800
RAILS_LOG_LEVEL=info
NODE_ENV=production
# ==== 2. LLM PROVIDER =================================================
#
# Pick exactly one provider for LLM_PROVIDER. Supported values:
# mock — deterministic fake responses. No network, no money.
# openai — OpenAI / OpenRouter / Azure OpenAI / any OpenAI-compatible API
# anthropic — Anthropic Messages API
# ollama — local Ollama server (https://ollama.com)
# openclaw — hanarang-internal OpenClaw runtime (most external users won't have this)
LLM_PROVIDER=mock
# Per-role model override. Leave empty to use the defaults baked into roles.ts
# (which are OpenClaw-flavored names — you probably need to set these for
# openai / anthropic / ollama).
#
# Good starting points:
# OpenAI: gpt-4o / gpt-4o-mini
# Anthropic: claude-opus-4-6 / claude-haiku-4-5
# Ollama: qwen2.5-coder:32b / qwen2.5-coder:7b
#
# LLM_MODEL_MANAGER=gpt-4o
# LLM_MODEL_PRINCIPAL=gpt-4o
# LLM_MODEL_LEAD=gpt-4o-mini
# LLM_MODEL_JUNIOR=gpt-4o-mini
# LLM_MODEL_FALLBACK=gpt-4o-mini
# ── OpenAI (and OpenAI-compatible) ────────────────────────────────────
# OPENAI_API_KEY=sk-...
# OPENAI_BASE_URL=https://api.openai.com/v1
# (also works for OpenRouter, Azure OpenAI, local llama.cpp servers, etc.)
# ── Anthropic ─────────────────────────────────────────────────────────
# ANTHROPIC_API_KEY=sk-ant-...
# ANTHROPIC_BASE_URL=https://api.anthropic.com
# ── Ollama (local) ────────────────────────────────────────────────────
# OLLAMA_BASE_URL=http://localhost:11434
# ── OpenClaw (internal) ───────────────────────────────────────────────
# OPENCLAW_BIN=/home/you/.npm-global/bin/openclaw
# ==== 3. TRANSPORT / TOPOLOGY =========================================
#
# rails supports three deployment topologies:
#
# 1. in-process — everything in one Node process. The simplest. The 4
# sister agents are just function calls inside rails.
# Requires sister-agent to be built under
# ./sister-agent/dist/.
#
# 2. http — rails calls each sister-agent over HTTP. The sister
# agents run as separate daemons (potentially on separate
# machines/containers). Production topology.
#
# 3. mock — no LLM, no files, no push. Just exercises the FSM.
#
# Set via RAILS_TRANSPORT globally, or per-stage via RAILS_TRANSPORT_PLAN etc.
RAILS_TRANSPORT=in-process
# For http mode — each sister-agent daemon's HTTP endpoint:
# SISTER_ENDPOINT_PLAN=http://harang-lxc:18801
# SISTER_ENDPOINT_IMPLEMENT=http://narang-lxc:18801
# SISTER_ENDPOINT_REVIEW=http://darang-lxc:18801
# SISTER_ENDPOINT_DEPLOY=http://erang-lxc:18801
# Loopback URL that sister-agent uses to report sub-task events back to rails.
# Usually the same as your rails HTTP URL as seen from the sister.
RAILS_API_URL=http://127.0.0.1:18800
# For in-process mode, optional override of where to load the compiled
# sister-agent core module from. Defaults to ./sister-agent/dist/core.js
# SISTER_AGENT_CORE_PATH=/app/sister-agent/dist/core.js
# Where sister-agent writes per-pipeline workspaces on disk.
# SISTER_WORKSPACE_DIR=/home/you/rails-projects
# ==== 4. OPTIONAL — Gitea auto-push ===================================
#
# When enabled, each pipeline run auto-creates a public repo and pushes its
# workspace to Gitea, giving you a shareable URL for the generated files.
# Leave GITEA_TOKEN empty to skip the push step entirely.
# GITEA_BASE_URL=https://git.example.com
# GITEA_ORG=my-org
# GITEA_TOKEN=
# GIT_USER_NAME=rails-agent
# GIT_USER_EMAIL=rails@example.com
# GIT_PUSH_ENABLED=true # force on/off; default is auto (on iff GITEA_TOKEN set)
# ==== 5. OPTIONAL — Gitea webhook receiver ============================
# GITEA_WEBHOOK_SECRET=
# ──────────────────────────────────────────────────────────────────────
# Discord integration is NOT handled inside rails. The hanarang 4-sister
# deployment uses OpenClaw's built-in Discord gateway, and the slash
# command (`/hanarang_rails ...`) is exposed via an OpenClaw skill whose
# SKILL.md frontmatter has `user-invocable: true`. The skill's handler
# script POSTs to rails HTTP API just like any other caller.
#
# See ~/.openclaw/skills/hanarang-rails/SKILL.md for the wiring.
# ──────────────────────────────────────────────────────────────────────

344
.plans/design/hierarchy.md Normal file
View File

@@ -0,0 +1,344 @@
# Design — Hierarchical Sub-Agent Team (Manager / Principal / Lead / Junior)
> **목적**: 각 stage 의 agent(부장) 가 팀을 꾸려 작업을 분산시키도록 한다.
> 평면 구조 (stage 당 1명) → 조직 구조 (부장 + 수석 + 선임 + 신입).
## 왜 필요한가
현재 rails 는 stage 당 agent 1명이 전부 처리하는 구조. 이건:
- ❌ 병렬성 낭비 — 큰 태스크도 순차 처리
- ❌ 모델 비용 비효율 — 모든 작업을 고가 모델로
- ❌ 결과 품질 저하 — 한 모델이 전략+전술+실행 전부 담당
- ❌ 실제 팀 구조와 미스매치
## 조직 구조
```
manager (부장) — 전략, 최종 승인
└── principal (수석) — 태스크 분해, 기술 리뷰
└── lead (선임) — 실행 리드, 작은 팀 조율
└── junior (신입) — 개별 태스크 실행
```
**엄격한 한 계단씩 아님** — 복잡도에 따라 manager 가 직접 lead 또는 junior 를 바로 spawn 할 수도 있다. 결정은 manager 의 "판단 코드".
## 역할 정의 (`roles.yaml`)
```yaml
hierarchy:
manager:
korean: 부장
responsibilities: [strategy, team-composition, final-approval, escalation-relay]
models:
primary: gpt-5.4
fallback: glm-5.1
can_spawn: [principal, lead, junior] # 복잡도에 따라 직접 spawn 가능
max_spawn_per_call: 4 # 한 번에 최대 4개 팀원
principal:
korean: 수석
responsibilities: [task-decomposition, technical-review, risk-assessment]
models:
primary: gpt-5.4
fallback: glm-5.1
can_spawn: [lead, junior]
max_spawn_per_call: 3
lead:
korean: 선임
responsibilities: [execution-lead, sub-team-coordination, mid-validation]
models:
primary: gpt-codex-5.3
fallback: glm-5
can_spawn: [junior]
max_spawn_per_call: 4
junior:
korean: 신입
responsibilities: [single-task-execution, unit-output]
models:
primary: glm-5-turbo
fallback: gpt-5
can_spawn: []
max_spawn_per_call: 0
# LXC 별 동시 실행 상한 — narang 은 빌드 중이라 보수적
concurrency_limits:
default: 8
overrides:
narang: 6
```
## 복잡도 판단 (Complexity Scoring)
Manager 가 태스크를 받으면 먼저 복잡도 점수(0-100) 를 계산한다. 이 점수로
팀 구성 규모가 결정된다.
### 점수 요소 (Deterministic)
| 요소 | 조건 | 점수 |
|---|---|---|
| **Scope scale** (from description + keywords) | 단일 파일 / "한 줄" | +0~5 |
| | 컴포넌트 1개 / small feature | +5~15 |
| | 여러 파일 / 멀티 모듈 | +15~30 |
| | Sprint 단위 | +30~50 |
| | 전체 프로젝트 / architecture | +50~80 |
| | From scratch / scaffold | +70~100 |
| **Multi-domain** (+5 each, cap +20) | frontend / backend / db / infra / ci / security / test 언급 | max +20 |
| **Risk keywords** (+10 each, cap +30) | migration / breaking / security / auth / data-loss | max +30 |
| **Parallelism hints** (+5 each, cap +15) | "multiple" / "동시에" / "parallel" / "bulk" | max +15 |
| **Uncertainty** | "probably" / "maybe" / "아직 모르겠" | +10 |
| **Estimated LOC** | >500 추정 | +10 |
| **Cross-agent dependency** | 다른 stage 와 명시 연관 | +10 |
### Tier → Decomposition Plan
| Score | Tier | 전략 |
|---|---|---|
| 0-15 | **trivial** | Manager 직접 처리 (spawn X) |
| 16-30 | **simple** | 1 junior |
| 31-50 | **moderate** | 1 lead + 1-2 junior |
| 51-75 | **complex** | 1 principal + 2 lead + 4 junior |
| 76-100 | **massive** | 2 principal + 각자 팀 (병렬 fanout) |
### LLM 보강 (optional)
규칙 기반 점수 + 기본 plan 을 cheap LLM 에게 주고
"이 plan 이 맞는지 / 조정 필요한지" 판단받음. 규칙 + LLM 합의가 최종 plan.
## 상향 에스컬레이션 (Upward Escalation)
하위 역할이 실패하면 **즉시 상위** 로 에스컬레이션 (재시도 아님).
```
junior 실패 (confidence < 0.5 or 명시적 escalate)
→ lead 가 해당 태스크 재수행
→ 또 실패
→ principal
→ 또 실패
→ manager
→ 또 실패
→ rails orchestrator → 사용자
```
같은 역할로 재시도는 resilience retry 가 담당 (Sprint 005).
위 상향 에스컬레이션은 **서로 다른 역할** 로 넘기는 흐름.
## 데이터 모델 (Prisma)
### SubTask
```prisma
model SubTask {
id String @id @db.VarChar(26) // ULID
pipelineId String @db.VarChar(26)
parentId String? @db.VarChar(26) // null = manager 직속
role String @db.VarChar(30) // manager|principal|lead|junior
agentName String @db.VarChar(50) // harang|narang|darang|erang
title String @db.VarChar(500)
description String @db.Text
state String @db.VarChar(30) // queued|running|done|failed|escalated
complexityScore Int?
complexityTier String? @db.VarChar(20)
model String @db.VarChar(50) // 사용 모델
resultJson String? @db.LongText
errorReason String? @db.Text
startedAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
parent SubTask? @relation("SubTaskHierarchy", fields: [parentId], references: [id])
children SubTask[] @relation("SubTaskHierarchy")
events SubTaskEvent[]
@@index([pipelineId, parentId])
@@index([state])
@@index([agentName, state])
}
```
### SubTaskEvent
```prisma
model SubTaskEvent {
id Int @id @default(autoincrement())
subTaskId String @db.VarChar(26)
eventType String @db.VarChar(50) // spawned|started|progress|output|completed|failed|escalated
payloadJson String @db.LongText
timestamp DateTime @default(now())
subTask SubTask @relation(fields: [subTaskId], references: [id], onDelete: Cascade)
@@index([subTaskId, timestamp])
@@index([eventType])
}
```
## Sister-Agent 데몬 (LXC 에 배포)
각 sister LXC (104/105/106/107) 에 Node.js 데몬이 돈다. 포트 **18801** (openclaw-gateway 와 분리).
### 책임
1. rails 로부터 `POST /invoke` 수신
2. 복잡도 점수 계산
3. Decomposition plan 생성
4. sub-agent spawn (OpenClaw 를 경유하거나 LLM 직접 호출)
5. sub-task 이벤트를 rails 에 실시간 push
6. 결과 집계 후 rails 에 HandoffMessage 반환
### 디렉토리 (new sub-project under rails repo)
```
hanarang-rails/
└── sister-agent/
├── package.json
├── tsconfig.json
├── roles.yaml
└── src/
├── server.ts # HTTP /invoke 엔드포인트
├── complexity/
│ ├── scorer.ts # 규칙 기반 점수 계산
│ └── planner.ts # decomposition 전략
├── hierarchy/
│ ├── roles.ts # YAML 로더
│ ├── spawn.ts # openclaw agent spawn wrapper
│ └── escalate.ts # 상향 에스컬레이션
├── reporting/
│ └── rails-client.ts # rails API 로 이벤트 push
└── index.ts
```
### 통신 프로토콜
#### 1. rails → sister-agent: `POST /invoke`
```json
{
"pipelineId": "01HW...",
"contractId": "01HW...",
"stage": "implement",
"task": {
"title": "Sprint 001 — todo app MVP",
"description": "Next.js + Nest.js 로 기본 TODO CRUD",
"workdir": "/home/narang/projects/todo-app"
},
"timeoutMs": 600000,
"railsApiUrl": "http://10.10.10.169:18800"
}
```
#### 2. sister-agent → rails: `POST /api/sub-tasks`
```json
{
"id": "01HW...",
"pipelineId": "01HW...",
"parentId": null,
"role": "manager",
"agentName": "narang",
"title": "root task",
"description": "...",
"complexityScore": 42,
"complexityTier": "moderate",
"model": "gpt-5.4"
}
```
#### 3. sister-agent → rails: `POST /api/sub-tasks/:id/events`
```json
{
"eventType": "spawned",
"payload": { "childId": "01HW..." }
}
```
#### 4. sister-agent → rails: `POST /invoke` 응답 (HandoffMessage)
```json
{
"stage": "implement",
"verdict": "IMPL_DONE",
"payload": {
"branch": "feature/sprint-001",
"commits": ["abc1234"],
"workdir": "/home/narang/projects/todo-app",
"selfTestReport": { "typecheck": "pass" }
},
"errorReason": ""
}
```
## LLM 호출 전략 (현실적)
초기 구현은 **openclaw CLI wrapping** 으로 간다:
```bash
openclaw agent \
--prompt "$(cat prompt.txt)" \
--model gpt-5.4 \
--output json
```
sister-agent 가 sub-process 로 `openclaw agent` 를 호출하고 stdout 을
structured JSON 으로 파싱한다. 이게 안 되면 OpenAI/Z.ai SDK 직접 호출로
fallback.
## 관제 대시보드 연동
대시보드는 `sub_tasks` 테이블을 트리 구조로 렌더링한다:
```
Pipeline 01KNV4...
├── 🦊 하랑 (manager) — planning [45s] [gpt-5.4]
│ ├── principal: 요구사항 분해 [done, 12s] [gpt-5.4]
│ └── lead: Sprint 분해 [done, 18s] [gpt-codex-5.3]
│ ├── junior: SPRINT-001 문서 [done, 4s] [glm-5-turbo]
│ ├── junior: SPRINT-002 문서 [done, 5s] [glm-5-turbo]
│ └── junior: SPRINT-003 문서 [done, 4s] [glm-5-turbo]
├── ⚙️ 나랑 (manager) — implementing [current] [gpt-5.4]
│ ├── principal: 아키텍처 검토 [done, 8s] [glm-5.1]
│ └── lead: 코딩 리드 [running] [gpt-codex-5.3]
│ ├── junior: frontend scaffold [running] [glm-5-turbo]
│ └── junior: backend scaffold [queued] [glm-5-turbo]
```
각 노드 click → 상세 모달 (prompt / output / timing / 모델 / 비용).
## 관찰 가능성 (Observability)
모든 서브태스크 전이가 `SubTaskEvent` 로 기록되고 `POST /api/stream`
(Socket.IO) 을 통해 대시보드에 실시간 푸시된다. SIEM 관점에서:
- `spawned` — 부모 노드 등록
- `started` — 실제 LLM 호출 시작
- `progress` — 중간 출력 (streaming 지원 시)
- `output` — 부분 결과
- `completed` — 성공 종료
- `failed` — 실패 종료 (재시도 대상)
- `escalated` — 상위로 에스컬레이션
## 보안
- sister-agent 가 받는 task 는 rails 에서 HMAC 서명 포함 (nonce 재사용 방지)
- sister-agent ↔ rails 통신은 내부 네트워크 (vmbr1) 한정
- 모델 API 키는 각 sister LXC 의 openclaw 설정에 이미 있음 — sister-agent 는
openclaw CLI 만 wrapping 하면 키 노출 없음
- rails DB 의 `resultJson` 에 비밀이 들어가지 않도록 sister-agent 가 masking
## 참고
- `state-machine.md` — pipeline FSM (stage 단위)
- `handoff.md` — rails ↔ sister (stage 단위 HandoffMessage)
- `retry-policy.md` — 같은 역할 재시도 정책
- `transports.md` — SisterTransport 추상화 (HttpTransport 가 여기 들어감)
## Open questions (Stage 2 에서 결정)
- [ ] Sub-task streaming output 은 SSE 로 할지 Socket.IO 로 할지
- [ ] 모델 비용 트래킹을 SubTask 에 추가할지
- [ ] Token 수 트래킹
- [ ] Escalation 시 부모 sub-task 의 retry count 합산 로직

68
Dockerfile Normal file
View File

@@ -0,0 +1,68 @@
# ────────────────────────────────────────────────────────────────
# Dockerfile — builds rails + sister-agent + CLI into one image
#
# The image starts `rails serve` in single-process (in-process) mode.
# For distributed mode, see docker-compose.full.yml which uses the same
# image but overrides CMD / env vars per container.
# ────────────────────────────────────────────────────────────────
FROM node:22-bookworm-slim AS builder
RUN corepack enable \
&& apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates openssl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy manifests first for better layer caching
COPY package.json pnpm-lock.yaml tsconfig.json ./
COPY prisma ./prisma
COPY sister-agent/package.json ./sister-agent/
COPY sister-agent/tsconfig.json ./sister-agent/
# Install deps (root + sister-agent workspace — sister-agent has its own lockfile)
RUN pnpm install --frozen-lockfile
WORKDIR /app/sister-agent
RUN pnpm install --frozen-lockfile || pnpm install
WORKDIR /app
# Copy sources
COPY src ./src
COPY sister-agent/src ./sister-agent/src
# Generate prisma client + build both
RUN npx prisma generate \
&& pnpm build \
&& cd sister-agent && pnpm build
# ────────────── runtime image ──────────────
FROM node:22-bookworm-slim
RUN corepack enable \
&& apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates openssl wget \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/package.json /app/pnpm-lock.yaml ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/sister-agent/package.json ./sister-agent/
COPY --from=builder /app/sister-agent/node_modules ./sister-agent/node_modules
COPY --from=builder /app/sister-agent/dist ./sister-agent/dist
ENV NODE_ENV=production
ENV RAILS_PORT=18800
ENV RAILS_TRANSPORT=in-process
ENV SISTER_AGENT_CORE_PATH=/app/sister-agent/dist/core.js
ENV SISTER_WORKSPACE_DIR=/app/rails-projects
EXPOSE 18800
# Default command: run migrations then start the HTTP server
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/cli/index.js serve"]

View File

@@ -20,15 +20,19 @@
| 2 | Skill 강제 진입 hook + bypass 감지 + 차단 | [SPRINT-002](.plans/sprints/SPRINT-002-enforcement.md) | cc:완료 [PR#2] |
| 3 | Sprint Contract + DoD validator (Zod) | [SPRINT-003](.plans/sprints/SPRINT-003-contract.md) | cc:완료 [PR#3] |
| 4 | 4자매 핸드오프 엔진 (상태 전이 기반) | [SPRINT-004](.plans/sprints/SPRINT-004-handoff.md) | cc:완료 [PR#4] |
| 5 | 재시도 / 타임아웃 / 에스컬레이션 policy | [SPRINT-005](.plans/sprints/SPRINT-005-resilience.md) | cc:WIP |
| 6 | QA 체크리스트 템플릿 + 다랑이 runtime | [SPRINT-006](.plans/sprints/SPRINT-006-qa.md) | cc:TODO |
| 7 | 기존 프로젝트 마이그레이션 (아랑 등) | [SPRINT-007](.plans/sprints/SPRINT-007-migration.md) | cc:TODO |
| 5 | 재시도 / 타임아웃 / 에스컬레이션 policy | [SPRINT-005](.plans/sprints/SPRINT-005-resilience.md) | cc:완료 [PR#5] |
| 6 | QA 체크리스트 템플릿 + 다랑이 runtime | [SPRINT-006](.plans/sprints/SPRINT-006-qa.md) | cc:완료 [PR#6] |
| 7 | 기존 프로젝트 마이그레이션 (아랑 등) | [SPRINT-007](.plans/sprints/SPRINT-007-migration.md) | cc:완료 [PR#7] |
## 현재 스프린트
**Sprint 005 — 재시도 / 타임아웃 / 에스컬레이션 policy** (`cc:TODO`)
**전체 완료** — v0.1.0 릴리즈 준비됨. 105 테스트 통과.
다음 착수 예정. 상세는 `.plans/sprints/SPRINT-001-skeleton.md` 참조.
다음 단계 (post-v0.1.0, 운영자 작업):
1. Dev 서버에 rails 배포 (`bash install.sh`)
2. DB 마이그레이션 (`pnpm prisma migrate deploy`)
3. Discord 봇 연동 (`docs/discord-setup.md` 참조)
4. 첫 실제 프로젝트 E2E 실행
## 마커 범례

193
README.md
View File

@@ -1,81 +1,184 @@
# hanarang-rails
> **4자매가 달릴 결정론적 레일** — HaNaRang Rails
> **4 자매가 달릴 결정론적 레일** — HaNaRang Rails
>
> _사용자는 출발 버튼만 누른다. 나머지는 자매들이 자동으로 달린다._
`hanarang-harness`의 후계작. 기존 하네스가 "권고 기반 파이프라인"이라 자매들이 레일을 벗어나 끊기고 엇갈리던 문제를 **강제 기반 결정론 파이프라인**으로 재설계한다.
`hanarang-rails` 는 4 개의 AI "자매" 에이전트 (하랑 / 나랑 / 다랑 / 이랑) 가 하나의 요청을 받아 **기획 → 구현 → 리뷰 → 배포**를 자동으로 완주하는 결정론적 파이프라인 오케스트레이터다. 전임자 `hanarang-harness` 가 권고 기반이라 자매들이 중간에 길을 잃던 문제를, XState 유한 상태 기계 (FSM) 와 Sprint Contract 로 물리적으로 강제한다.
## 왜 다시?
- **처음 보는 사람을 위한 완전 가이드**: [`docs/GUIDE.md`](docs/GUIDE.md) / [`docs/GUIDE.pdf`](docs/GUIDE.pdf)
- **설계 문서**: [`.plans/design/`](.plans/design/)
- **스프린트 명세**: [`.plans/sprints/`](.plans/sprints/)
- **실패 감사 (F1F6)**: [`.plans/failure-audit.md`](.plans/failure-audit.md)
[`hanarang-harness`](https://git.nabomhalang.co.kr/hanarang/openclaw-harness)에서 발견된 6가지 실패 모드:
---
## 한 문단 요약
사용자가 `"todo 앱 만들어 줘"` 한 줄을 던지면, rails 오케스트레이터가 **하랑이 (기획) → 나랑이 (구현) → 다랑이 (리뷰) → 이랑이 (배포)** 순서로 파이프라인을 돌린다. 각 자매는 내부에서 **부장/수석/선임/신입** 4 단계 계층으로 태스크를 쪼개서 병렬 실행하고, 만들어낸 코드 파일은 자동으로 Gitea 에 public repo 로 push 되어 즉시 접근 가능한 URL 로 바뀐다. 대시보드에서는 이 모든 과정이 실시간으로 트리 형태로 보인다.
## 왜 다시 만들었는가
[`hanarang-harness`](https://git.nabomhalang.co.kr/hanarang/openclaw-harness) 에서 4 개월 운영하며 발견한 6 가지 고질 실패 모드:
| 코드 | 증상 | 원인 |
|---|---|---|
| F1 | 하네스 skill bypass — 자매가 worker 혼자 스폰하고 처리 | skill 진입 강제 없음 |
| F2 | DoD 자동 강제 실패 — build 통과 = 완료로 간주 | sprint contract / validator 없음 |
| F1 | 하네스 skill bypass — 자매가 혼자 worker 스폰 | skill 진입 강제 없음 |
| F2 | DoD 자동 강제 실패 — `build` 통과 = 완료로 판정 | sprint contract / validator 없음 |
| F3 | QA 단계 누락 — 사용자가 수동으로 다랑이 호출 | 자동 라우팅 없음 |
| F4 | 핸드오프 멘션 불안정 — 잘못된 자매 호출 | Lobster 분기가 LLM에 의존 |
| F5 | 중간 끊김 — request-timed-out 반복, xhigh 무한대기 | 재시도/fallback 정책 없음 |
| F6 | 환경 검증 누락 — "서버에 Docker 없음" 으로 skip 용 | 환경 전제 검사 없음 |
| F4 | 핸드오프 멘션 불안정 — 잘못된 자매 호출 | 분기가 LLM 판단에 의존 |
| F5 | 중간 끊김 — `request-timed-out` 반복 | 재시도/에스컬레이션 정책 없음 |
| F6 | 환경 검증 누락 — "Docker 없음" 으로 skip 용 | 환경 전제 검사 없음 |
## 6가지 원칙
본질 한 줄: **"자매가 하네스를 안 타고 본인이 처리한다."**
1. **결정론적 라우터** — LLM 판단이 아니라 XState FSM으로 자매 간 전이
2. **Sprint Contract 강제** — DoD를 Zod 스키마로 정의, validator가 pass/fail 판정
3. **Skill 강제 진입** — skill bypass를 hook으로 감지해 차단
4. **상태 전이 기반 핸드오프** — 멘션은 사용자 알림 전용, 자매 간 통신은 FSM 상태
5. **재시도/에스컬레이션** — timeout 자동 재시도, N회 실패 시 사용자 에스컬레이션
6. **QA 체크리스트 강제** — 스프린트 타입별 템플릿, 다랑이가 체크박스 다 채워야 pass
## 6 가지 설계 원칙 (하드 룰)
1. **강제 > 권고** — 모든 파이프라인 전이는 코드로 강제한다.
2. **결정론적 FSM** — 자매 간 핸드오프는 XState 상태 전이다.
3. **Sprint Contract = 불변 계약** — DoD 를 Zod 스키마로 정의, validator 가 pass/fail 판정.
4. **Skill 강제 진입** — skill bypass 를 hook 이 감지해 차단.
5. **QA 체크리스트 의무** — 다랑이가 체크박스 전부 채워야 PASS.
6. **환경 검증 선행** — 실기동 검증 환경 없으면 스프린트 시작 자체를 거부.
## 아키텍처 개요
```
사용자 (디스코드)
────────────────────────────────────┐
hanarang-rails orchestrator │
(XState FSM + SQLite + validator)
──────────────────┬────────────────
┌───────────┼───────────┬───────────┐
▼ ▼ ▼ ▼
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
하랑 │ │ 나랑 │ │ 다랑 │ │ 이랑
│Planner│ │ Impl │ │ QA │ │Deploy│
└──────┘ └──────┘ └──────┘ └──────┘
│ │ │ │
└───────────┴─ OpenClaw spawn ──────┘
┌────────────┐
│ Discord 알림│ ← 사용자 알림 전용
└────────────┘
사용자 (Discord / Dashboard Web)
┌─────────────────────────────┐
hanarang-dashboard │ Next.js 16 + NestJS
│ /rails, /office, /sisters
└────────────┬────────────────┘
HTTP
┌─────────────────────────────┐
│ hanarang-rails orchestrator │ XState + Prisma + MariaDB
FSM ─ Contract ─ Hierarchy
└────────────┬────────────────┘
│ HTTP invoke
┌──────────┼──────────┬──────────┐
▼ ▼ ▼
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
│하랑 │ │나랑 │ │다랑 │ │이랑 │ sister-agent × 4 LXC
│plan │ │impl │ │review│ │deploy│
└──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘
└─────────┴─ openclaw CLI ────┘ (LLM: gpt-5.4 등)
┌───────────────┐
│ Gitea SSOT │ git.nabomhalang.co.kr
│ (auto-push) │ public repo per pipeline
└───────────────┘
```
## 기술 스택
| 레이어 | 선택 |
|---|---|
| 런타임 | Node 22 + TypeScript (strict) |
| 런타임 | Node 22 + TypeScript strict |
| 상태 머신 | XState v5 |
| 스키마 | Zod |
| 영속화 | SQLite (better-sqlite3) |
| DB | MariaDB (Prisma) |
| 프로세스 | execa + AbortController |
| CLI | citty |
| 로그 | pino |
| 디스코드 | discord.js v14 |
| 테스트 | Vitest |
| 프론트엔드 (대시보드) | Next.js 16 + styled-components |
| 백엔드 (대시보드) | NestJS + Socket.IO |
## 상태
🚧 **기획 단계**`.plans/` 디렉토리 참조.
- **v0.1.0** — Sprint 000~007 완료. FSM / Contract / QA / Migration 코어. 105 테스트 통과.
- **v0.1.1** — 실 LLM 통합 (OpenClaw infer), 4 계층 재귀 스폰, 파일 추출, Gitea auto-push, deploy URL.
- **v0.1.2** — 대시보드 아티팩트 뷰, FileViewerModal (MD 파일 클릭 → 모달).
- **v0.1.3** — LLM 제공자 어댑터 (OpenAI / Anthropic / Ollama / OpenClaw / mock), in-process 단일 프로세스 모드, docker-compose, Gitea 호스트 완전 외부화, 외부 배포 친화 .env.example.
자세한 내용:
## 빠른 시작 (Docker, 5 분)
가장 짧은 경로. 로컬에 `docker``docker compose` 만 있으면 된다.
```bash
git clone https://git.nabomhalang.co.kr/hanarang/hanarang-rails.git
cd hanarang-rails
cp .env.example .env
# .env 에서 LLM_PROVIDER=mock 으로 시작 (또는 openai/anthropic/ollama)
docker compose up --build
# → http://localhost:18800/health 확인
# 다른 터미널
curl -X POST http://localhost:18800/pipelines/start \
-H 'content-type: application/json' \
-d '{"project":"hello","requirements":"Say hi"}'
```
이게 끝. MariaDB + rails + sister-agent 4 개가 한 컨테이너 안에서 **in-process 모드** 로 돈다. 자세한 설정 옵션 (실제 LLM 키 연결, 네이티브 설치, 분산 토폴로지, 대시보드) 은 [`docs/LOCAL-SETUP.md`](docs/LOCAL-SETUP.md) 참조.
### 배포 토폴로지
| 모드 | 설명 | 파일 |
|---|---|---|
| **in-process** | 모든 것을 하나의 Node 프로세스에서. 로컬 개발 기본값 | `docker-compose.yml` · `rails.config.local.yaml` |
| **http 분산** | rails + 4 개 독립 sister-agent 컨테이너. 운영 토폴로지 | `docker-compose.full.yml` · `rails.config.distributed.yaml` |
| **mock** | FSM 만 검증 (LLM/파일/push 없음) | `RAILS_TRANSPORT=mock` 또는 `rails run --mock` |
### LLM 제공자
어댑터가 있어 다음 중 하나를 선택할 수 있다. `.env``LLM_PROVIDER` 로 지정:
- `mock` — API 키 없이 결정론 스켈레톤만 확인 (기본값)
- `openai` — OpenAI / OpenRouter / Azure OpenAI / OpenAI-호환 로컬 서버
- `anthropic` — Anthropic Messages API
- `ollama` — 로컬 Ollama 서버
- `openclaw` — hanarang 내부 전용 런타임
## CLI 서브커맨드
| 명령 | 용도 |
|---|---|
| `rails start` | 파이프라인 생성 |
| `rails run [--mock]` | E2E 실행 |
| `rails status [id]` | 상태 조회 + 타임라인 |
| `rails resume <id>` | escalated → idle 재개 |
| `rails abort <id>` | 강제 종료 |
| `rails contract generate/freeze/validate/show` | Sprint Contract 관리 |
| `rails qa run/show/templates` | QA 템플릿 실행 |
| `rails skill-context create/show/clear` | Skill 강제 진입 |
| `rails skill-trace show/blocked` | 도구 사용 감사 로그 |
| `rails doctor` | 환경 헬스체크 |
| `rails scaffold` | 신규 프로젝트 `.plans/` 생성 |
| `rails migrate from-hanarang-harness <path>` | 레거시 하네스 스캔 |
| `rails serve` | 오케스트레이터 HTTP 서버 |
## HTTP API (orchestrator, 18800)
| 메서드 | 경로 | 설명 |
|---|---|---|
| GET | `/health` | 헬스체크 |
| GET | `/pipelines?limit=N` | 파이프라인 리스트 |
| GET | `/pipelines/:id` | 파이프라인 상세 |
| POST | `/pipelines/start` | 새 파이프라인 실행 |
| POST | `/pipelines/:id/abort` | 강제 종료 |
| GET | `/api/pipelines/:id/sub-tasks` | SubTask 트리 |
| GET | `/api/sub-tasks/:id` | 서브태스크 상세 |
| GET | `/api/transitions` | 상태 전이 이력 |
| GET | `/api/escalations` | 에스컬레이션 큐 |
## 문서
- [`docs/LOCAL-SETUP.md`](docs/LOCAL-SETUP.md) — **30 분 퀵스타트** (본인 환경에서 처음 돌려 보기)
- [`docs/GUIDE.md`](docs/GUIDE.md) — **완전 가이드** (전 구간 해설, 처음 보는 사람용)
- [`docs/GUIDE.pdf`](docs/GUIDE.pdf) — 위 문서의 PDF 버전
- [`docs/migration-guide.md`](docs/migration-guide.md) — 레거시 → rails 이전 가이드
- [`docs/operations.md`](docs/operations.md) — 운영 가이드 (PM2, 로그, DB)
- [`docs/discord-setup.md`](docs/discord-setup.md) — Discord 봇 연동 + marker 프로토콜
- [`.plans/OVERVIEW.md`](.plans/OVERVIEW.md) — 프로젝트 전체 개요
- [`.plans/failure-audit.md`](.plans/failure-audit.md) — 실패 감사
- [`.plans/design/`](.plans/design/) — 설계 문서
- [`.plans/failure-audit.md`](.plans/failure-audit.md) — F1F6 실패 감사
- [`.plans/design/`](.plans/design/) — 설계 문서 9 종
- [`.plans/sprints/`](.plans/sprints/) — 스프린트 상세
## 라이선스
MIT
MIT — 나봄하랑 / hanarang

141
docker-compose.full.yml Normal file
View File

@@ -0,0 +1,141 @@
# ────────────────────────────────────────────────────────────────
# docker-compose.full.yml — production-style distributed topology
#
# Brings up:
# - mariadb
# - rails (orchestrator only)
# - sister-harang (plan)
# - sister-narang (implement)
# - sister-darang (review)
# - sister-erang (deploy)
#
# All 6 services share the same image but each sister container runs the
# sister-agent HTTP daemon instead of the rails orchestrator, and rails
# is configured to talk to them over HTTP.
#
# Usage:
# cp .env.example .env
# docker compose -f docker-compose.full.yml up --build
# ────────────────────────────────────────────────────────────────
x-sister-env: &sister-env
NODE_ENV: production
RAILS_API_URL: http://rails:18800
SISTER_WORKSPACE_DIR: /app/rails-projects
LLM_PROVIDER: ${LLM_PROVIDER:-mock}
LLM_MODEL_MANAGER: ${LLM_MODEL_MANAGER:-}
LLM_MODEL_PRINCIPAL: ${LLM_MODEL_PRINCIPAL:-}
LLM_MODEL_LEAD: ${LLM_MODEL_LEAD:-}
LLM_MODEL_JUNIOR: ${LLM_MODEL_JUNIOR:-}
LLM_MODEL_FALLBACK: ${LLM_MODEL_FALLBACK:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-}
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-}
GITEA_BASE_URL: ${GITEA_BASE_URL:-}
GITEA_ORG: ${GITEA_ORG:-}
GITEA_TOKEN: ${GITEA_TOKEN:-}
x-sister-service: &sister-service
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
mariadb:
condition: service_healthy
command:
["node", "sister-agent/dist/server.js"]
networks:
- rails-net
services:
mariadb:
image: mariadb:10.11
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: rootpw
MARIADB_DATABASE: hanarang_rails
MARIADB_USER: rails
MARIADB_PASSWORD: rails
healthcheck:
test:
- "CMD"
- "healthcheck.sh"
- "--connect"
- "--innodb_initialized"
interval: 5s
timeout: 3s
retries: 20
volumes:
- rails-db:/var/lib/mysql
networks:
- rails-net
rails:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
mariadb:
condition: service_healthy
sister-harang: { condition: service_started }
sister-narang: { condition: service_started }
sister-darang: { condition: service_started }
sister-erang: { condition: service_started }
environment:
DATABASE_URL: mysql://rails:rails@mariadb:3306/hanarang_rails
RAILS_PORT: "18800"
RAILS_LOG_LEVEL: info
NODE_ENV: production
RAILS_TRANSPORT: http
RAILS_API_URL: http://rails:18800
SISTER_ENDPOINT_PLAN: http://sister-harang:18801
SISTER_ENDPOINT_IMPLEMENT: http://sister-narang:18801
SISTER_ENDPOINT_REVIEW: http://sister-darang:18801
SISTER_ENDPOINT_DEPLOY: http://sister-erang:18801
SISTER_NAME_PLAN: harang
SISTER_NAME_IMPLEMENT: narang
SISTER_NAME_REVIEW: darang
SISTER_NAME_DEPLOY: erang
ports:
- "18800:18800"
networks:
- rails-net
sister-harang:
<<: *sister-service
environment:
<<: *sister-env
SISTER_AGENT_NAME: harang
SISTER_AGENT_PORT: "18801"
sister-narang:
<<: *sister-service
environment:
<<: *sister-env
SISTER_AGENT_NAME: narang
SISTER_AGENT_PORT: "18801"
sister-darang:
<<: *sister-service
environment:
<<: *sister-env
SISTER_AGENT_NAME: darang
SISTER_AGENT_PORT: "18801"
sister-erang:
<<: *sister-service
environment:
<<: *sister-env
SISTER_AGENT_NAME: erang
SISTER_AGENT_PORT: "18801"
volumes:
rails-db:
networks:
rails-net:
driver: bridge

96
docker-compose.yml Normal file
View File

@@ -0,0 +1,96 @@
# ────────────────────────────────────────────────────────────────
# docker-compose.yml — single-host / in-process mode
#
# Spins up:
# - mariadb (10.11)
# - rails (orchestrator + 4 sister agents all in one process)
#
# Everything runs in one container so the 4 sisters are just function
# calls instead of 4 separate daemons. Pick this file when you want
# "docker compose up and try it".
#
# Usage:
# cp .env.example .env # set LLM_PROVIDER and API keys
# docker compose up --build
#
# Then hit http://localhost:18800/health to confirm.
#
# For the production topology with 4 separate sister daemons, see
# docker-compose.full.yml instead.
# ────────────────────────────────────────────────────────────────
services:
mariadb:
image: mariadb:10.11
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: rootpw
MARIADB_DATABASE: hanarang_rails
MARIADB_USER: rails
MARIADB_PASSWORD: rails
healthcheck:
test:
- "CMD"
- "healthcheck.sh"
- "--connect"
- "--innodb_initialized"
interval: 5s
timeout: 3s
retries: 20
volumes:
- rails-db:/var/lib/mysql
networks:
- rails-net
rails:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
mariadb:
condition: service_healthy
environment:
DATABASE_URL: mysql://rails:rails@mariadb:3306/hanarang_rails
RAILS_PORT: "18800"
RAILS_LOG_LEVEL: info
NODE_ENV: production
RAILS_TRANSPORT: in-process
RAILS_API_URL: http://127.0.0.1:18800
SISTER_AGENT_CORE_PATH: /app/sister-agent/dist/core.js
SISTER_WORKSPACE_DIR: /app/rails-projects
# LLM — read from .env
LLM_PROVIDER: ${LLM_PROVIDER:-mock}
LLM_MODEL_MANAGER: ${LLM_MODEL_MANAGER:-}
LLM_MODEL_PRINCIPAL: ${LLM_MODEL_PRINCIPAL:-}
LLM_MODEL_LEAD: ${LLM_MODEL_LEAD:-}
LLM_MODEL_JUNIOR: ${LLM_MODEL_JUNIOR:-}
LLM_MODEL_FALLBACK: ${LLM_MODEL_FALLBACK:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-}
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-}
# Gitea — leave empty to disable auto-push
GITEA_BASE_URL: ${GITEA_BASE_URL:-}
GITEA_ORG: ${GITEA_ORG:-}
GITEA_TOKEN: ${GITEA_TOKEN:-}
ports:
- "18800:18800"
volumes:
- rails-workspace:/app/rails-projects
networks:
- rails-net
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:18800/health"]
interval: 10s
timeout: 3s
retries: 10
volumes:
rails-db:
rails-workspace:
networks:
rails-net:
driver: bridge

906
docs/GUIDE.md Normal file
View File

@@ -0,0 +1,906 @@
---
title: "hanarang-rails 완전 가이드"
subtitle: "4자매 AI가 달리는 결정론적 파이프라인 — 처음 보는 사람을 위한 전 구간 해설"
author: "나봄하랑 / hanarang"
date: "2026-04-10"
geometry: margin=22mm
mainfont: "Noto Sans CJK KR"
monofont: "JetBrains Mono"
fontsize: 11pt
linkcolor: "NavyBlue"
urlcolor: "NavyBlue"
toc: true
toc-depth: 3
numbersections: true
---
\newpage
# 0. 이 문서는 누구를 위한 문서인가
이 문서는 **hanarang-rails 프로젝트를 처음 보는 사람**이 한 번 읽고 다음 세 가지를 완전히 이해할 수 있게 하는 것이 목표다.
1. **이 시스템이 무엇이고**, 왜 만들었으며, 어떤 문제를 해결하는지
2. **코드 한 줄부터 사용자 요청까지** 어떤 경로로 흐르는지
3. 직접 클론해서 **E2E 로 돌려보려면** 무엇이 필요한지
기존 AI 코딩 도구 (Claude Code, Cursor, Codex, OpenClaw) 를 써 본 경험이 있다면 이해가 빠르겠지만, 없어도 모든 용어는 문서 안에서 정의한다. LLM / 에이전트 / 파이프라인이라는 단어만 대충 알면 된다.
\newpage
# 1. 한 문단 요약
**hanarang-rails 는 4 개의 AI "자매" 에이전트가 하나의 요청을 받아서 기획 → 구현 → 리뷰 → 배포를 자동으로 끝내는 결정론적 파이프라인 오케스트레이터다.** 기존 하네스는 "이 단계가 끝나면 다음 자매를 호출해 줘" 라고 LLM 에게 부탁하는 방식이었고, 그래서 자매가 중간에 길을 잃으면 사용자가 끼어들어 중재해야 했다. hanarang-rails 는 그 흐름을 XState 유한 상태 기계 (FSM) 와 Sprint Contract (DoD 의 기계 검증본) 로 물리적으로 강제한다. 자매는 "권고"를 받는 것이 아니라 **레일 위를 달리는 열차**처럼, 갈 수 있는 다음 상태가 코드로 고정되어 있다.
\newpage
# 2. 배경 — 왜 다시 만들었는가
## 2.1 전임자 hanarang-harness 의 실패 모드
이전 프로젝트 `hanarang-harness` (Gitea 에 private archive 로 보존) 는 **권고 기반** 파이프라인이었다. 각 단계가 끝나면 LLM 이 "다음에 누구를 부르면 좋을지" 판단했고, 핸드오프는 Discord 멘션으로 전달되었다. 4 개월 운영하면서 다음 6 가지 고질 문제가 반복됐다.
| 코드 | 증상 | 원인 |
|---|---|---|
| F1 | 하네스 skill 우회 — 자매가 혼자 worker 스폰해서 처리 | skill 진입 강제 부재 |
| F2 | DoD 자동 강제 실패 — `build` 통과만 보고 완료 판정 | sprint contract / validator 부재 |
| F3 | QA 단계 누락 — 사용자가 수동으로 다랑이 호출 필요 | 자동 라우팅 없음 |
| F4 | 핸드오프 멘션 불안정 — 잘못된 자매 호출 | 분기가 LLM 판단에 의존 |
| F5 | 중간 끊김 — `request-timed-out` 반복, `xhigh` 무한 대기 | 재시도/에스컬레이션 정책 없음 |
| F6 | 환경 검증 누락 — "Docker 없음" 으로 작업 skip 허용 | 환경 전제 검사 없음 |
본질은 단 한 줄로 요약된다: **"자매가 하네스를 안 타고 본인이 처리한다."**
## 2.2 해결 전략 — 6 가지 설계 원칙
`.claude/rules/principles.md` 에 명시된 하드 룰이다. 이 원칙은 타협하지 않는다.
1. **강제 > 권고.** 모든 파이프라인 전이는 코드로 강제한다. LLM 판단에 맡기지 않는다.
2. **결정론적 FSM.** 자매 간 핸드오프는 XState 상태 전이다. 멘션은 사용자 알림 전용이다.
3. **Sprint Contract = 불변 계약.** 모든 스프린트는 시작 전에 `sprint-contract.json` 을 생성하고, DoD 를 Zod 스키마로 표현한 validator 가 pass / fail 을 판정한다. `build` 통과 = 완료는 금지다.
4. **Skill 강제 진입.** OpenClaw 자매가 하네스 skill 을 우회하면 post-hook 이 감지해 작업을 revert 한다.
5. **QA 체크리스트 의무.** 다랑이는 스프린트 타입별 체크리스트를 전부 체크해야 PASS 를 낼 수 있다.
6. **환경 검증 선행.** 실기동 검증 환경이 없으면 스프린트를 시작하지 않는다. "Docker 없음 → skip" 같은 escape hatch 는 contract 에서 사전 차단한다.
## 2.3 레일 메타포
왜 이름이 "rails" 인가?
- **레일 (rail) = XState FSM**: 갈 수 있는 경로를 물리적으로 제한
- **신호등 = Sprint Contract**: 다음 역으로 갈 수 있는 조건
- **역 (station) = 자매 작업 단계**: Plan / Implement / Review / Deploy
- **차단봉 = Skill 강제 진입 hook**
- **긴급 정차 버튼 = 에스컬레이션 policy**
- **중앙 통제소 = MariaDB orchestrator state**
사용자는 출발 버튼만 누르고, 긴급 상황에서만 호출된다.
\newpage
# 3. 4 자매는 누구인가
4 자매는 4 개의 서로 다른 LLM 에이전트다. 각자 성격/말투/역할이 다르고, OpenClaw 런타임 위에서 독립된 LXC 컨테이너에 돌아간다.
| 자매 | 영문 | 역할 | 단계 | 주 모델 |
|---|---|---|---|---|
| 하랑 | harang | Planner — 요구사항 해석, 계획 작성 | `plan` | gpt-5.4 |
| 나랑 | narang | Implementer — 코드/문서 생성 | `implement` | gpt-5.4 |
| 다랑 | darang | Reviewer — QA, 체크리스트 검증 | `review` | gpt-codex-5.3 |
| 이랑 | erang | Deployer — 배포 검증, 인프라 | `deploy` | glm-5-turbo |
각 자매는 내부적으로 **manager → principal → lead → junior** 4 단계 계층을 가진다. 사용자가 "X 를 만들어 줘" 라고 하면, 각 자매의 manager 가 태스크를 받고 복잡도에 따라 하위 junior / lead 에게 분배한다. 복잡한 태스크일수록 더 깊게 파고들어가 병렬 처리된다 (자세한 내용은 §7).
\newpage
# 4. 시스템 구성도
## 4.1 하이 레벨
```
┌──────────────────────────────────────────────────────────────┐
│ 사용자 (자기야) │
│ Discord / Dashboard Web │
└──────────────────────────────────┬───────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ hanarang-dashboard │
│ Next.js 16 (프론트) + NestJS (API) │
│ │
│ /rails, /rails/log, /rails/escalations, /office │
└──────────────────────────────────┬───────────────────────────┘
│ HTTP
┌──────────────────────────────────────────────────────────────┐
│ hanarang-rails orchestrator │
│ │
│ XState FSM ─┬─ Sprint Contract Validator │
│ ├─ SubTask Hierarchy Store │
│ ├─ MariaDB (Prisma) │
│ └─ HTTP API Server (citty + http) │
└──────────────────────────────────┬───────────────────────────┘
│ HTTP invoke
┌────────────┬────────────┼────────────┬────────────┐
▼ ▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ harang │ │ narang │ │ darang │ │ erang │
│ (LXC) │ │ (LXC) │ │ (LXC) │ │ (LXC) │
│ │ │ │ │ │ │ │
│sister- │ │sister- │ │sister- │ │sister- │
│ agent │ │ agent │ │ agent │ │ agent │
└───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘
│ │ │ │
└───────────┴─────┬─────┴───────────┘
┌──────────────┐
│ openclaw CLI │ (LLM 호출: gpt-5.4 등)
│ infer model │
└──────────────┘
┌──────────────┐
│ Gitea SSOT │ git.nabomhalang.co.kr
│ (output) │ auto-push, public repos
└──────────────┘
```
## 4.2 물리 토폴로지
| 역할 | 호스트 | IP | 내용 |
|---|---|---|---|
| 사용자 | 개인 PC | — | Discord 클라이언트, 대시보드 웹 브라우저 |
| Proxmox hypervisor | `192.168.1.31` | — | VM / LXC 전체 호스트 |
| Dev VM (SSOT) | VM 200 | `10.10.10.169` | `hanarang-rails` + `hanarang-dashboard` 실제 구동, PM2 |
| 하랑이 LXC | LXC | 내부망 | sister-agent daemon + OpenClaw 런타임 |
| 나랑이 LXC | LXC | 내부망 | 〃 |
| 다랑이 LXC | LXC | 내부망 | 〃 |
| 이랑이 LXC | LXC | 내부망 | 〃 |
| Gitea | Docker | `git.nabomhalang.co.kr` | SSOT 저장소 (public + private), SSH 2222 |
| MariaDB | Dev VM | `10.10.10.169:3306` | `hanarang_rails` DB |
하나의 파이프라인 요청은 최대 10 개 이상의 서브 프로세스로 확장될 수 있다 (4 자매 × manager/principal/lead/junior 계층). 병렬 실행은 `Promise.all` 기반이고, 동시 실행 한도는 자매별로 설정 가능하다 (기본 8, 나랑이는 6).
\newpage
# 5. 데이터 모델 — MariaDB 스키마
`prisma/schema.prisma` 에 정의되어 있다. 파이프라인 한 번의 실행이 각 테이블에 남기는 흔적을 따라가면 시스템 전체가 보인다.
## 5.1 테이블 요약
| 테이블 | 설명 | 키 |
|---|---|---|
| `pipelines` | 하나의 파이프라인 실행 (= 사용자 요청 1 회) | ULID |
| `state_transitions` | FSM 상태 전이 로그 (감사 용) | auto |
| `sub_tasks` | 자매/역할별 서브 태스크 트리 | ULID |
| `sub_task_events` | 서브 태스크 수명 이벤트 (spawned/started/completed/failed) | auto |
| `contracts` | Sprint Contract 스냅샷 (DoD + validator 정의) | ULID |
| `escalations` | 사용자 개입이 필요해진 예외 상황 | ULID |
| `actor_spawns` | 자매 프로세스 스폰 로그 (레거시) | auto |
## 5.2 Pipeline 레코드의 생애
```
idle ─(START)─▶ running ─(ALL_STAGES_DONE)─▶ completed
├─(TIMEOUT 3회)──▶ escalated
└─(FATAL_ERROR)──▶ failed
```
`currentState` 는 XState 의 현재 노드, `contextJson` 은 FSM 의 context (전 단계 결과물 포함) 을 serialize 한 것이다. 매 전이마다 `StateTransition` row 가 한 줄씩 추가되므로, 나중에 `GET /api/transitions?pipelineId=…` 로 전체 이력을 재생할 수 있다.
## 5.3 SubTask 트리
각 파이프라인은 여러 개의 `sub_tasks` 를 만든다. 예를 들어 "todo 앱 만들어 줘" 라는 요청 하나가 다음 트리를 만들 수 있다.
```
harang-manager (role=manager, stage=plan)
└─ harang-principal (plan 의 세부 항목 3 개를 쪼갬)
├─ harang-lead-1
└─ harang-lead-2
narang-manager (role=manager, stage=implement)
├─ narang-lead-frontend
│ ├─ narang-junior-html
│ ├─ narang-junior-css
│ └─ narang-junior-js
└─ narang-lead-backend
└─ narang-junior-api
darang-manager (role=manager, stage=review)
erang-manager (role=manager, stage=deploy)
```
`parentId` 체인으로 트리를 재구성할 수 있고, 대시보드의 "서브태스크 상세 드로어" 가 이 트리를 직접 렌더링한다.
\newpage
# 6. Orchestrator — XState FSM 엔진
`src/orchestrator/` 는 파이프라인의 심장이다.
## 6.1 파일 구조
| 파일 | 역할 |
|---|---|
| `machine.ts` | XState `setup({types}).createMachine(…)` 로 FSM 정의 |
| `runner.ts` | 파이프라인 실행 루프 (actor 생성, 이벤트 dispatch, 단계 간 체이닝) |
| `persist.ts` | `getPersistedSnapshot()` 으로 FSM 상태를 DB 에 왕복 저장 |
| `context.ts` | FSM context 타입 (pipelineId, stage 결과, priorStages…) |
| `events.ts` | `START`, `STAGE_DONE`, `TIMEOUT`, `FATAL_ERROR` 등 이벤트 스키마 |
## 6.2 상태 흐름
```
[ idle ]
│ START
[ running ]
├─ stage="plan" ──▶ spawn harang ──▶ priorStages.push
│ │
├─ stage="implement" ──▶ spawn narang ──┤
│ │
├─ stage="review" ──▶ spawn darang ─────┤
│ │
└─ stage="deploy" ──▶ spawn erang ──────┤
[ completed ]
```
각 stage 는 순차적으로 실행되지만, **stage 내부** 에서는 계층 구조 (manager → principal → lead → junior) 가 `Promise.all` 로 병렬 실행된다. 그래서 한 stage 안에 10 개 이상의 junior 가 동시에 코드를 쓰는 일이 자주 생긴다.
## 6.3 priorStages 체이닝
가장 중요한 구조적 결정. `plan` 의 결과물 텍스트가 `implement` 의 프롬프트에 통째로 들어간다. `implement` 가 만든 파일 목록이 `review` 의 입력이 되고, `review` 의 verdict 가 `deploy` 의 컨텍스트가 된다. 자매는 다음 자매의 결과물을 모른 채 일하지 않는다.
구현:
```ts
// src/orchestrator/runner.ts
const priorStages: PriorStageOutput[] = [];
for (const stage of ["plan", "implement", "review", "deploy"]) {
const result = await invokeSister(stage, { priorStages });
priorStages.push({ stage, text: extractStageText(result) });
}
```
`extractStageText` 는 결과물 JSON 에서 `summary`, `repoUrl`, `rawUrlBase`, `producedFiles`, `filesCount` 를 뽑아 자연어 요약으로 합친다.
\newpage
# 7. 역할 계층 — Manager/Principal/Lead/Junior
## 7.1 왜 계층이 있는가
LLM 한 개에게 "todo 앱 풀스택으로 만들어 줘" 라고 던지면 컨텍스트 한계에 부딪힌다. 사람 팀과 똑같이, 부장은 방향을 결정하고 신입은 코드를 친다. 이걸 구조적으로 강제하면 LLM 의 약점 (컨텍스트 파편화, 집중력 분산) 을 회피할 수 있다.
## 7.2 역할 정의 (`src/hierarchy/roles.ts`)
| Role | 한국어 | 주 모델 | 하위 스폰 가능 | 최대 스폰 |
|---|---|---|---|---|
| manager | 부장 | gpt-5.4 | principal, lead, junior | 4 |
| principal | 수석 | gpt-5.4 | lead, junior | 3 |
| lead | 선임 | gpt-codex-5.3 | junior | 4 |
| junior | 신입 | glm-5-turbo | (없음) | 0 |
manager 는 직접 코드를 짜지 않는다. 대신 하위 직원에게 쪼개서 던진다. junior 는 리프 노드이며 실제 파일 생성을 책임진다.
## 7.3 복잡도 스코어 (`src/hierarchy/complexity.ts`)
태스크가 들어오면 먼저 complexity 점수를 계산한다.
```
score = (길이_점수 × 0.3)
+ (키워드_점수 × 0.5)
+ (범위_점수 × 0.2)
```
키워드 "풀스택", "데이터베이스", "인증", "배포", "아키텍처" 등은 가산점. 최종 score (0100) 는 tier 로 매핑된다.
| Tier | 점수 | 권장 분해 |
|---|---|---|
| trivial | 020 | junior 한 명 |
| simple | 2140 | lead 한 명 또는 junior 2 |
| moderate | 4160 | principal 1, lead 1, junior 23 |
| complex | 6180 | principal 1, lead 2, junior 4 |
| massive | 81100 | principal 2, lead 3, junior 6+ |
이 분해는 `planner.ts``DecompositionPlan` 으로 표현되고, `spawn.ts` 의 재귀 트리 워커가 그걸 받아 실제 LLM 호출 그래프를 만든다.
\newpage
# 8. Sister Agent — 레일 위를 달리는 열차
`sister-agent/` 는 각 LXC 에 독립적으로 배포되는 daemon 이다. 네 자매 모두 동일한 코드 베이스를 쓰지만, 환경변수 `AGENT_NAME` (harang / narang / darang / erang) 로 정체성을 구분한다.
## 8.1 엔드포인트
```
POST /invoke
Content-Type: application/json
{
"pipelineId": "01HXXXX...",
"stage": "implement",
"task": { "title": "...", "description": "...", "workdir": "" },
"priorStages": [ { "stage": "plan", "text": "..." } ],
"timeoutMs": 600000,
"railsApiUrl": "http://10.10.10.169:18800"
}
```
리턴은 `HandoffMessage` 디스크리미네이티드 유니온이다.
```
{ "stage": "implement", "verdict": "IMPL_DONE",
"payload": { "branch":"main", "commits":[...], "workdir":"...",
"selfTestReport": { "producedFiles":[...], "repoUrl":"..." }}}
```
## 8.2 실행 파이프라인 (`src/spawn.ts`)
```
runSpawnNode(ctx, node)
├─ prompts.build(role, stage, task, priorStages) // 한국어 역할 프롬프트
├─ llm.infer(prompt, model) // openclaw CLI 호출
├─ maybeExtractFiles(llmText, role, stage) // 코드 블록 파싱
│ ├─ ```lang:path 패턴 감지
│ ├─ 파일 경로 sanitize
│ └─ ctx.producedFiles.push(`${stage}/files/${path}`)
├─ for child of node.children: // 하위 직원 재귀
│ await runSpawnNode(ctx, child) // Promise.all
└─ buildSuccessResult(node, producedFiles)
```
## 8.3 LLM 호출 — `openclaw infer model run`
각 자매는 로컬에서 `openclaw infer model run --model gpt-5.4 --json` 서브프로세스를 실행한다. stdout 은 Zod 로 검증된 후 쓴다. LLM 응답의 결정론성은 아래 세 가지로 관리한다.
1. **엄격한 프롬프트 템플릿** — 역할/단계별 한국어 템플릿이 `prompts.ts` 에 고정
2. **구조화 응답 요구** — "이 형식 밖으로 나가면 재시도" 지시를 프롬프트 끝에 삽입
3. **코드 블록 규약**` ```lang:path/to/file.ext` 형태로 내놓으라고 명시, 파서가 이걸 기대
## 8.4 코드 블록 추출 (`src/code-extractor.ts`)
LLM 응답에서 파일을 꺼내는 로직이다. 기대 포맷:
````
```html:frontend/index.html
<!doctype html>
...
```
```css:frontend/style.css
body { ... }
```
````
파서는:
1. 정규식으로 ` ``` ` 블록 탐지
2. 언어 뒤의 `:path` 힌트 추출
3. path sanitize: `..`, 절대 경로, 백슬래시 금지
4. path 가 없으면 언어별 기본 파일명 (`snippet.html` 등)
5. `{ path, lang, content }` 리스트 반환
이 결과는 `maybeExtractFiles` 가 받아서 실제 파일로 쓰고 `ctx.producedFiles` 에 상대 경로를 기록한다.
\newpage
# 9. Git 자동 푸시 — Gitea 연동 (`git-ops.ts`)
파이프라인이 만든 파일은 즉시 Gitea 에 올라가 실행 가능한 URL 로 바뀐다.
## 9.1 흐름
1. implement stage 가 끝나면 `commitAndPush(pipelineId, workdir)` 호출
2. 리포 이름은 `rails-${pipelineId.slice(-10).toLowerCase()}` (예: `rails-abcd012345`)
3. Gitea API 로 `hanarang` org 에 public repo 자동 생성
`POST /api/v1/orgs/hanarang/repos`
4. 로컬 `git init` → 커밋 → `git push https://user:TOKEN@git.nabomhalang.co.kr/…`
5. 리턴:
```
{ ok:true, repoUrl:"https://git.nabomhalang.co.kr/hanarang/rails-abcd012345",
rawUrlBase:"https://git.nabomhalang.co.kr/hanarang/rails-abcd012345/raw/branch/main",
commit:"a1b2c3d", filesCount:7 }
```
## 9.2 프리뷰 URL 추론
`derivePreviewUrl` 이 `producedFiles` 에서 `.html` 파일을 찾아 `${rawUrlBase}/${html파일경로}` 로 즉시 열 수 있는 공개 URL 을 계산한다. 결과는 `selfTestReport.deployUrl` 에 들어가고, 대시보드의 "url" 배지가 달린 FileRow 로 사용자에게 보여진다. 클릭하면 브라우저에서 바로 열린다.
## 9.3 왜 Gitea 인가
- `git.nabomhalang.co.kr` 은 우리 내부 SSOT 서버다 (Docker 로 Dev VM 에서 돌고 있음)
- `gh` CLI 는 GitHub 전용이라 쓸 수 없고, 대신 `tea` CLI 또는 REST API 로 접근한다
- 토큰: `.env` 의 `GITEA_TOKEN` 에 저장, 코드에서는 URL 에 `user:TOKEN@` 형태로만 사용
\newpage
# 10. 대시보드 — hanarang-dashboard
`hanarang-dashboard` 는 별도 repo 이며, rails 가 돌고 있는 모든 것을 시각화한다. Next.js 16 (Turbopack) + NestJS API + Socket.IO 실시간 이벤트로 만들어졌다.
## 10.1 페이지
| 경로 | 설명 |
|---|---|
| `/rails` | 활성 파이프라인 리스트 + SubTask 트리 시각화 |
| `/rails/log` | 상태 전이 감사 로그 (SIEM 스타일) |
| `/rails/escalations` | 에스컬레이션 큐 |
| `/office` | 4 자매 대화 스트림 (사용자가 구경하는 용) |
| `/sisters/[name]` | 자매 개별 프로필 + 통계 |
## 10.2 SubTask 상세 드로어
`/rails` 에서 노드를 클릭하면 우측 드로어가 열린다. 이 드로어에 들어가는 정보:
- **헤더**: 자매 아바타, role 배지, title, breadcrumb (부모 체인)
- **상태/모델 그리드**: state, agent, model, duration, complexity, ID
- **설명**: 태스크 description
- **산출물 (Artifacts)**:
- `.md` 로그 파일 → 클릭 시 모달로 내용 표시 (`FileViewerModal`)
- 추출된 코드 파일 → 클릭 시 Gitea 프록시로 페치해서 표시
- Deploy URL → 브라우저 외부 링크
- **LLM 응답**: `react-markdown` 으로 렌더링 (front matter 는 분리)
- **하위 노드 리스트**: children 요약
- **이벤트 로그**: SubTaskEvent 전체
## 10.3 FileViewerModal
가장 최근에 추가된 기능. 대시보드에서 파일 내용을 보고 싶을 때 쓴다.
```
┌────────────────────────────────────┐
│ FILE implement/files/index.html │
│ [복사] [닫기] │
├────────────────────────────────────┤
│ <!doctype html> │
│ <html> │
│ ... │
└────────────────────────────────────┘
```
두 가지 소스 타입을 받는다:
1. `{ type: 'llm', text }` — 이미 메모리에 있는 LLM 결과물 (로그 .md 용)
2. `{ type: 'url', url }` — Gitea raw URL, 백엔드 `/api/rails/file-content` 프록시로 페치
프록시는 Gitea 호스트만 allowlist 한다 (`git.nabomhalang.co.kr`). 외부 URL 은 거부.
`.md` 파일은 `react-markdown` 으로, 아닌 것은 `<pre>` 로 표시. Front matter (`--- ... ---`) 는 상단 메타 박스로 분리한다.
\newpage
# 11. End-to-End 시나리오 — "todo 앱 만들어 줘"
처음 보는 사람이 가장 궁금해할 "한 번의 실행" 을 코드 흐름으로 따라가자.
## 11.1 Step 0 — 트리거
사용자가 Discord 에 다음과 같이 쓴다.
```
/rails start project:todo-app requirements:"간단한 todo 웹앱 하나 만들어 줘"
```
Discord 봇은 이걸 HTTP 요청으로 바꿔 Dev 서버 대시보드 백엔드로 보낸다.
```
POST http://dev-vm/api/rails/pipelines/start
{
"project": "todo-app",
"requirements": "간단한 todo 웹앱 하나 만들어 줘"
}
```
## 11.2 Step 1 — 오케스트레이터 진입
대시보드 백엔드 (`RailsService`) 가 rails orchestrator 에 포워딩.
```
POST http://127.0.0.1:18800/pipelines/start
```
rails 는:
1. ULID 를 발급해 `pipelines` 테이블에 새 row 를 만든다 (`currentState='idle'`)
2. XState actor 를 생성해 `START` 이벤트 dispatch → `running` 상태로 전이
3. `state_transitions` 에 `idle → running` 한 줄 기록
4. 4 단계 루프를 시작한다
## 11.3 Step 2 — Plan (하랑이)
rails 는 harang LXC 의 `/invoke` 로 POST:
```
{ stage:"plan", task:{ title:"todo-app", description:"..."}, priorStages:[] }
```
harang sister-agent 는:
1. `harang-manager` SubTask row 생성, `sub_task_events` 에 `spawned`, `started` 이벤트
2. complexity 계산 → 점수 35 → `simple` tier → principal 1 + junior 1 로 분해
3. 각 하위 노드를 `Promise.all` 로 LLM 호출
4. junior 가 반환한 계획을 manager 가 취합, ` ```md:plan.md` 코드 블록으로 감싼 응답을 만듦
5. `maybeExtractFiles``plan/files/plan.md` 로 저장, producedFiles 에 기록
6. `{stage:"plan", verdict:"PLAN_READY", payload:{ planDir, sprintId, selfTestReport:{producedFiles} }}` 리턴
rails 는 결과를 `priorStages[0]` 에 푸시한다.
## 11.4 Step 3 — Implement (나랑이)
```
POST /invoke
{ stage:"implement",
task:{...},
priorStages:[ { stage:"plan", text:"<plan.md 요약>" } ] }
```
narang sister-agent 는:
1. complexity 60 → `moderate` → principal 1 + lead 2 (frontend/backend) + junior 4
2. 병렬로 LLM 호출, junior 들이 각각 HTML / CSS / JS / server.js 를 생성
3. 모든 산출물을 `implement/files/...` 로 저장
4. `git-ops.commitAndPush(pipelineId, workdir)` 호출
- Gitea 에 `rails-abcd012345` repo 생성
- `git push` 성공
- `repoUrl`, `rawUrlBase`, `commit` 리턴
5. `derivePreviewUrl(producedFiles, rawUrlBase)``https://…/implement/files/frontend/index.html` 계산
6. `{stage:"implement", verdict:"IMPL_DONE", payload:{ ..., selfTestReport:{ producedFiles, repoUrl, rawUrlBase, deployUrl } }}` 리턴
## 11.5 Step 4 — Review (다랑이)
```
POST /invoke
{ stage:"review",
priorStages:[
{ stage:"plan", text:"..." },
{ stage:"implement", text:"repoUrl=...\nfilesCount=7\n..." }
]}
```
darang 은 rawUrlBase 로 Gitea 파일을 직접 페치해서 읽고, QA 체크리스트를 돌린다. 결과는 `verdict:"APPROVE" | "REQUEST_CHANGES" | "ABORT"`.
REQUEST_CHANGES 가 나오면 rails 는 implement 로 되돌려 재시도 (최대 3 회). 3 회 실패 시 `escalated` 상태로 전이하고 사용자에게 알림.
## 11.6 Step 5 — Deploy (이랑이)
erang 은 deploy URL 이 실제로 열리는지 verify, 필요하면 추가 설정 파일을 쓴다. 최종적으로 `{stage:"deploy", verdict:"DEPLOY_DONE", payload:{ deployArtifactPath, verificationResults }}`.
## 11.7 Step 6 — 완료
rails 는 `running → completed` 로 전이, 대시보드 Socket.IO 로 실시간 브로드캐스트. 사용자 Discord 에는 최종 deploy URL 이 포스트된다.
```
✅ todo-app 완료
repo: https://git.nabomhalang.co.kr/hanarang/rails-abcd012345
deploy: https://git.nabomhalang.co.kr/hanarang/rails-abcd012345/raw/branch/main/implement/files/frontend/index.html
duration: 4m 12s
sub-tasks: 11 (완료 11, 실패 0)
```
\newpage
# 12. HTTP API 명세
rails orchestrator 가 노출하는 엔드포인트. 대시보드 백엔드와 sister-agent 가 소비한다.
| 메서드 | 경로 | 설명 |
|---|---|---|
| GET | `/health` | 헬스체크 |
| GET | `/pipelines?limit=N` | 파이프라인 리스트 |
| GET | `/pipelines/:id` | 파이프라인 상세 (state, context, transitions) |
| POST | `/pipelines/start` | 새 파이프라인 실행 |
| POST | `/pipelines/:id/abort` | 파이프라인 강제 종료 |
| GET | `/api/pipelines/:id/sub-tasks` | SubTask 트리 |
| GET | `/api/sub-tasks/:id` | 서브태스크 상세 (parents/children/events) |
| GET | `/api/transitions?pipelineId=...&limit=100` | 상태 전이 이력 |
| GET | `/api/escalations?pipelineId=...&resolved=false` | 에스컬레이션 큐 |
대시보드 쪽 (`backend/src/rails/`) 은 이것들을 래핑해서 `/api/rails/*` 로 재노출하고, 인증/인가를 한 겹 더 얹는다.
\newpage
# 13. Sprint Contract — DoD 의 기계 검증
## 13.1 왜 필요한가
F2 실패 모드 ("build 통과 = 완료") 를 막기 위해서. 스프린트가 시작되기 전에 "이 스프린트는 무엇으로 끝난 것으로 보는가" 를 기계가 읽을 수 있는 형태로 고정한다.
## 13.2 구조
`.claude/state/contracts/<task-id>.sprint-contract.json`
```json
{
"taskId": "11.2",
"sprintId": "SPRINT-003",
"version": "v1",
"checks": [
{ "id": "files-exist", "type": "file-exists", "paths": ["src/contract/generator.ts"] },
{ "id": "tests-pass", "type": "command-success", "cmd": "pnpm test src/contract" },
{ "id": "schema-valid", "type": "artifact-schema", "path": "out/contract.json", "schema": "ContractSchema" }
],
"nonGoals": ["UI 변경"],
"reviewerProfile": "static",
"riskFlags": ["security-sensitive"]
}
```
## 13.3 체크 타입 (`src/contract/checks/`)
| 타입 | 의미 |
|---|---|
| `file-exists` | 경로 존재 여부 |
| `command-success` | 쉘 명령 exit code 0 |
| `http-status` | URL 응답 2xx |
| `regex-in-file` | 파일 내용이 정규식 매칭 |
| `artifact-schema` | JSON 산출물이 Zod 스키마 통과 |
| `db-query` | DB 쿼리가 기대 행 수 리턴 |
| `process-listening` | 포트 LISTEN 확인 |
| `manual` | 수동 체크박스 (escape hatch, 최소화 권장) |
하나라도 FAIL 이 나오면 스프린트는 `cc:완료` 가 될 수 없다.
\newpage
# 14. 보안 모델
## 14.1 신뢰 경계
| 경계 | 정책 |
|---|---|
| 사용자 → 대시보드 | 세션 쿠키 인증 (NestJS) |
| 대시보드 → rails | 내부망 전용 HTTP, 토큰 없음 (향후 추가 예정) |
| rails → sister-agent | 내부망 HTTP, `AGENT_NAME` 환경변수로 정체성 고정 |
| sister-agent → LLM | OpenClaw 런타임이 API 키 관리 |
| rails → Gitea | `.env``GITEA_TOKEN`, URL 에만 주입 |
## 14.2 Gitea 프록시 allowlist
대시보드 백엔드 `/api/rails/file-content``URL.host === 'git.nabomhalang.co.kr'` 만 허용. 외부 URL 은 404 를 돌려준다. 이유: 악의적 링크로 백엔드에서 임의 HTTP 요청을 트리거하는 SSRF 공격 방지.
## 14.3 Zod 검증 경계
모든 외부 입력 (HTTP body, subprocess stdout, 파일 로드) 은 Zod 스키마를 통과한 뒤에만 내부 타입으로 들어온다. 경계 밖에서는 `any` 금지.
\newpage
# 15. 실패/복원력 (`src/resilience/`)
## 15.1 재시도 정책
Exponential backoff — `1s, 2s, 4s, 8s, 최대 30s`. 기본 3 회. 매 재시도는 `sub_task_events``retry` 이벤트로 기록된다.
## 15.2 타임아웃
자매 `/invoke` 응답 기본 600 초 (LLM 이 오래 걸릴 수 있어서). 초기에는 30 초로 잡았다가 `request-timed-out` 재현 → 600 초로 변경.
## 15.3 에스컬레이션
N 회 실패 시 `escalations` 테이블에 row 추가, Discord 에 사용자 멘션. 상태는 `escalated` 로 전이하고 파이프라인은 정지한다. 사용자가 `rails resume <id>` 를 호출하면 `escalated → running` 으로 복구.
\newpage
# 16. 설치/실행 가이드
## 16.1 사전 요구
- Node 22 + pnpm
- MariaDB 10.11+
- Gitea 인스턴스 (또는 환경변수 `GITEA_TOKEN` + `GITEA_API_URL` 재설정)
- OpenClaw 런타임 (각 자매 LXC)
## 16.2 rails 서버 구동
```bash
git clone https://git.nabomhalang.co.kr/hanarang/hanarang-rails.git
cd hanarang-rails
pnpm install
cp .env.example .env
# DATABASE_URL, GITEA_TOKEN 등 채우기
pnpm prisma migrate deploy
pnpm build
pnpm rails serve # 18800 포트
```
## 16.3 sister-agent 구동 (각 LXC)
```bash
cd sister-agent
pnpm install
pnpm build
AGENT_NAME=harang RAILS_API_URL=http://dev-vm:18800 \
node dist/server.js
```
## 16.4 대시보드 구동
별도 repo `hanarang-dashboard` 참조. `pnpm build && pm2 start ecosystem.config.js`.
## 16.5 스모크 테스트
```bash
pnpm rails run hello-world --mock -r "Try a pipeline"
pnpm rails status
```
`--mock` 모드는 실제 LLM 호출 없이 결정론 파이프라인만 확인한다.
\newpage
# 17. 디렉토리 구조
```
hanarang-rails/
├── src/
│ ├── orchestrator/ FSM 엔진
│ ├── hierarchy/ 계층/복잡도/플래너
│ ├── handoff/ 자매 간 메시지 스키마 + 트랜스포트
│ ├── contract/ Sprint Contract + validator
│ ├── qa/ QA 체크리스트 runtime
│ ├── enforcement/ Skill 강제 진입 / bypass 감지
│ ├── resilience/ 재시도/타임아웃/에스컬레이션
│ ├── server/http.ts HTTP API 서버
│ ├── cli/ citty 기반 rails CLI
│ ├── config/ env + config loader
│ └── bridge/ Discord 브릿지 (v0.2 예정)
├── sister-agent/
│ └── src/
│ ├── server.ts /invoke HTTP 서버
│ ├── spawn.ts 재귀 트리 실행기
│ ├── hierarchy.ts 역할 트리 builder
│ ├── complexity.ts 스코어 계산
│ ├── planner.ts 복잡도 → 분해 계획
│ ├── roles.ts 역할 정의
│ ├── prompts.ts 한국어 프롬프트 템플릿
│ ├── llm.ts openclaw CLI wrapper
│ ├── code-extractor.ts 코드 블록 파서
│ ├── git-ops.ts Gitea API + git push
│ └── rails-client.ts rails 에 이벤트 report
├── prisma/schema.prisma DB 스키마
├── .plans/
│ ├── OVERVIEW.md
│ ├── failure-audit.md
│ ├── design/ 설계 문서 9 종
│ ├── sprints/ 스프린트 000007 명세
│ └── migration/
├── docs/
│ ├── GUIDE.md ★ 이 문서
│ ├── migration-guide.md
│ ├── operations.md
│ └── discord-setup.md
├── hooks/ OpenClaw pre/post-tool hooks
├── qa-templates/ QA 체크리스트 6 종
├── install.sh 설치 자동화
└── rails.config.example.yaml
```
\newpage
# 18. 로드맵
| 버전 | 상태 | 내용 |
|---|---|---|
| v0.1.0 | 완료 | Sprint 000007, FSM/contract/QA/migration 코어 |
| v0.1.1 | 완료 | 실 LLM 통합, 계층 실행, 파일 추출, Gitea auto-push |
| v0.1.2 | 완료 | 대시보드 아티팩트 뷰, MD 파일 뷰어 모달 |
| v0.2 | 진행 | Discord 브릿지 정식화, GatewayHttpTransport 분리 |
| v0.3 | 계획 | Skill 강제 진입 실측, OpenClaw hook 프로덕션 적용 |
| v0.4 | 계획 | 멀티 테넌시 (여러 사용자 동시 실행) |
| v1.0 | 계획 | 외부 공개 + 문서화 완성 |
\newpage
# 19. 용어집
| 용어 | 정의 |
|---|---|
| **자매 (Sister)** | 4 개의 LLM 에이전트 중 하나 (harang/narang/darang/erang) |
| **자기야** | 사용자 (나봄하랑) 에 대한 4 자매의 호칭 |
| **OpenClaw** | 하나랑 생태계에서 사용하는 AI 런타임. Claude Code 기반이지만 별개 브랜드 |
| **Rails** | 이 프로젝트. 결정론적 파이프라인 오케스트레이터 |
| **Harness** | rails 의 전임자 `hanarang-harness`. 권고 기반이라 실패가 잦았음 |
| **FSM** | Finite State Machine. XState 로 구현 |
| **Sprint Contract** | 스프린트 시작 전에 쓰는 DoD 기계 검증 스펙 |
| **DoD** | Definition of Done. 완료 조건 |
| **SubTask** | 자매/역할별로 쪼개진 서브 태스크 |
| **Stage** | 파이프라인의 주 단계 (plan/implement/review/deploy) |
| **Role** | 자매 내부의 직급 (manager/principal/lead/junior) |
| **Escalation** | 자동 복구 실패 시 사용자에게 넘기는 예외 상황 |
| **priorStages** | 이전 단계 결과물 텍스트의 누적 배열 |
| **producedFiles** | 자매가 이번 실행에서 만든 파일의 상대 경로 리스트 |
| **SSOT** | Single Source of Truth. 여기서는 Dev VM 위의 Gitea + MariaDB |
| **LXC** | 리눅스 컨테이너. Proxmox 에서 각 자매를 격리 실행 |
\newpage
# 20. 참고 자료
- 원본 실패 감사: `.plans/failure-audit.md`
- 설계 문서: `.plans/design/state-machine.md`, `sprint-contract.md`, `hierarchy.md`, `deployment.md`, `handoff.md`, `retry-policy.md`, `qa-template.md`, `transports.md`, `triggers.md`
- 스프린트 명세: `.plans/sprints/SPRINT-000` ~ `SPRINT-007`
- 마이그레이션 가이드: `docs/migration-guide.md`
- 운영 가이드: `docs/operations.md`
- Discord 셋업: `docs/discord-setup.md`
- 전임자 repo: `hanarang/openclaw-harness` (private archive)
- 대시보드 repo: `hanarang/hanarang-dashboard`
\newpage
# 부록 A. 용례 비교 — 구 하네스 vs rails
## A.1 핸드오프
**구 하네스**
```
harang → "이제 나랑이가 구현해 주세요" (Discord 멘션)
narang → 잠시 뒤 멘션을 본다 (혹은 못 봄)
→ 본인 판단으로 스폰, 직접 처리
→ skill 을 안 탐 (F1)
```
**rails**
```
stage="plan" → FSM context.priorStages.push({ stage:"plan", text:... })
XState transition(STAGE_DONE) → guard 검사 → next state="implement"
runner 가 자동으로 POST /invoke (stage=implement) → narang 실행
narang 은 선택권이 없다. 호출된 대로만 실행
```
## A.2 DoD
**구 하네스**: `npm run build` 가 exit 0 → 완료 처리.
**rails**: Sprint Contract 의 `checks[]` 가 전부 pass 해야 `cc:완료`. `artifact-schema` 체크는 산출물 JSON 을 Zod 로 한 번 더 검증한다.
## A.3 QA
**구 하네스**: 사용자가 "다랑아 이거 QA 해 줘" 라고 멘션. 다랑이가 답장 없음 → 사용자가 중재.
**rails**: FSM 이 자동으로 `review` stage 로 전이. 다랑이는 반드시 호출되고, QA 템플릿의 체크리스트를 전부 채워야 `APPROVE` 를 낼 수 있다.
\newpage
# 부록 B. 자주 묻는 질문
**Q. 왜 Claude Code 가 아니라 OpenClaw 인가?**
A. OpenClaw 는 하나랑이 내부에서 쓰는 커스텀 런타임이다. Claude Code 를 포크한 것이 아니라 별개의 구현이다. 4 자매는 OpenClaw 위에 올라가 있고, 이 rails 레포 자체는 Claude Code 세션에서 개발한다.
**Q. 왜 SQLite 가 아니라 MariaDB 를 쓰나?**
A. 초기 설계에서는 SQLite 를 썼지만, Dev VM 에 MariaDB 가 이미 있고 대시보드가 같은 DB 를 공유하는 게 간단해서 MariaDB 로 옮겼다. Prisma 로 추상화되어 있어 다시 바꾸는 것도 어렵지 않다.
**Q. 병렬 실행은 어디까지 가능한가?**
A. stage 는 순차 (plan → implement → …), stage 내부의 junior 스폰은 병렬. 기본 `default:8` 동시 실행, 나랑이는 빌드 자원 때문에 6 으로 제한. `concurrencyLimits.overrides` 로 자매별 조정 가능.
**Q. LLM 이 헛소리를 하면?**
A. 세 겹의 방어가 있다. (1) 프롬프트 템플릿이 구조화 응답을 강제. (2) Zod 가 응답을 검증, 실패 시 재시도. (3) Sprint Contract 가 최종 산출물을 정적 검증.
**Q. 사용자 개입 없이 며칠 단위 장기 태스크가 가능한가?**
A. 현재 v0.1.x 는 한 번의 파이프라인 = 한 번의 기획 → 배포 사이클이다. 더 긴 수명의 프로젝트는 여러 파이프라인을 엮는 방식으로 다룬다. v0.4 멀티 테넌시에서 검토 예정.
**Q. 테스트는 어떻게?**
A. Vitest 105 테스트가 현재 통과. FSM, contract, QA, migration 핵심 경로를 커버한다. E2E 는 `--mock` 모드로 돌릴 수 있다.
\newpage
# 부록 C. 라이선스 및 기여
- 라이선스: MIT (`LICENSE`)
- 저작권: 나봄하랑 / hanarang
- 기여: PR 환영. `.plans/` 문서 규약을 따를 것.
- 문의: Discord 또는 Gitea issue
> 하나랑의 4 자매가 사용자 중재 없이 달릴 수 있는 레일을 깐다 —
> 그것이 이 프로젝트의 처음이자 끝의 목표다.

BIN
docs/GUIDE.pdf Normal file

Binary file not shown.

218
docs/LOCAL-SETUP.md Normal file
View File

@@ -0,0 +1,218 @@
# Local Setup — 30 분 퀵스타트
이 문서는 **본인 환경에서 hanarang-rails 를 처음부터 돌려 보는** 가장 짧은 경로다. 외부 인프라 (Gitea, OpenClaw, 4 개 LXC, MariaDB 전용 서버) 전혀 없어도 로컬에서 E2E 파이프라인을 한 번 돌리는 게 목표.
대상 독자: 이 리포를 처음 클론한 사람. Node 와 docker 를 쓸 줄 아는 사람.
---
## 0. 사전 요구
하나만 선택:
- **Option A — Docker 경로** (권장): `docker` + `docker compose` 만 있으면 끝. MariaDB 까지 컨테이너로 뜬다.
- **Option B — 네이티브 경로**: Node 22, pnpm 9, MariaDB 10.11+ 로컬 설치.
추가로 **LLM 제공자 하나**를 정해 둬야 한다.
| 제공자 | 필요한 것 | 비용 |
|---|---|---|
| `mock` | (없음) | 무료, 진짜 LLM 호출 없음 — FSM 만 확인 |
| `openai` | OpenAI API 키 | 사용량 기반 |
| `anthropic` | Anthropic API 키 | 사용량 기반 |
| `ollama` | 로컬 Ollama + 모델 pull | 무료, 로컬 GPU/CPU |
| `openclaw` | hanarang 내부 런타임 | 외부인 접근 불가 |
**처음이면 `mock` 으로 시작**하는 걸 권장한다. 실제 LLM 없이 파이프라인 전 구간이 동작하는지 먼저 확인하고, 그 다음 원하는 제공자로 바꿔도 늦지 않다.
---
## 1. Docker 경로 (권장)
### 1-1. 클론 + 환경 설정
```bash
git clone https://git.nabomhalang.co.kr/hanarang/hanarang-rails.git
cd hanarang-rails
cp .env.example .env
```
`.env` 에서 최소 이 두 줄만 만져 주면 된다:
```bash
# 모크 모드로 시작 (진짜 LLM 호출 안 함)
LLM_PROVIDER=mock
# Docker compose 가 쓸 DB URL
DATABASE_URL="mysql://rails:rails@mariadb:3306/hanarang_rails"
```
### 1-2. 기동
```bash
docker compose up --build
```
처음 빌드는 몇 분 걸린다. 완료되면 rails 컨테이너가 마이그레이션을 돌리고 HTTP 서버가 18800 포트에서 리스닝한다.
```bash
curl http://localhost:18800/health
# → {"ok":true,"service":"hanarang-rails"}
```
### 1-3. 파이프라인 첫 실행
다른 터미널에서:
```bash
curl -X POST http://localhost:18800/pipelines/start \
-H 'content-type: application/json' \
-d '{"project":"todo-app","requirements":"간단한 todo 웹앱"}'
```
응답으로 `pipelineId`, `finalState: done`, `transitions` 숫자가 돌아오면 성공. 파이프라인 상태는:
```bash
curl http://localhost:18800/pipelines/<ID>
```
### 1-4. 실제 LLM 로 갈아타기
`.env` 에서:
```bash
LLM_PROVIDER=openai
OPENAI_API_KEY=sk-...
LLM_MODEL_MANAGER=gpt-4o
LLM_MODEL_PRINCIPAL=gpt-4o
LLM_MODEL_LEAD=gpt-4o-mini
LLM_MODEL_JUNIOR=gpt-4o-mini
```
`docker compose up -d --build` 로 재시작. 같은 `curl` 명령을 또 날리면 이번에는 실제 LLM 이 호출되고, 각 junior 가 만든 코드 블록이 `rails-workspace` 볼륨 안으로 저장된다.
> **Anthropic / Ollama / OpenAI 호환 서버** 도 같은 패턴이다. `LLM_PROVIDER` 만 바꾸고 해당 API 키/URL 를 `.env` 에 채워 주면 된다. `.env.example` 파일 주석에 각 제공자별 키 이름이 정리돼 있다.
---
## 2. 네이티브 경로
Docker 없이 로컬 프로세스로 돌리는 경로.
### 2-1. MariaDB 준비
```bash
# brew / apt / 도커 중 편한 방법으로 MariaDB 10.11+ 기동
# 그 다음 DB/사용자 생성:
mysql -u root -p <<SQL
CREATE DATABASE hanarang_rails;
CREATE USER 'rails'@'localhost' IDENTIFIED BY 'rails';
GRANT ALL ON hanarang_rails.* TO 'rails'@'localhost';
SQL
```
### 2-2. 클론 + 빌드
```bash
git clone https://git.nabomhalang.co.kr/hanarang/hanarang-rails.git
cd hanarang-rails
pnpm install
# sister-agent 도 별도 install
cd sister-agent && pnpm install && cd ..
```
### 2-3. 환경 설정
```bash
cp .env.example .env
# 편집:
# DATABASE_URL="mysql://rails:rails@localhost:3306/hanarang_rails"
# LLM_PROVIDER=mock
# RAILS_TRANSPORT=in-process
cp rails.config.local.yaml rails.config.yaml
```
### 2-4. DB 마이그레이션 + 빌드
```bash
pnpm prisma migrate deploy
pnpm prisma generate
pnpm build
cd sister-agent && pnpm build && cd ..
```
### 2-5. 기동 + 테스트
```bash
pnpm rails serve -c rails.config.yaml
```
다른 터미널:
```bash
curl -X POST http://localhost:18800/pipelines/start \
-H 'content-type: application/json' \
-d '{"project":"hello","requirements":"Say hi"}'
```
---
## 3. 파일은 어디로 가나?
- Docker 경로: rails 컨테이너의 `/app/rails-projects/<pipelineId>/<stage>/files/` 에 저장되고, `rails-workspace` named volume 에 영속화된다. 컨테이너 밖에서 보려면 `docker compose run --rm rails ls /app/rails-projects/<pipelineId>` 또는 볼륨 mount 변경.
- 네이티브 경로: `$HOME/rails-projects/<pipelineId>/<stage>/files/`.
Gitea auto-push 는 기본적으로 꺼져 있다. 켜고 싶으면 `.env``GITEA_TOKEN`, `GITEA_BASE_URL`, `GITEA_ORG` 를 채우면 자동으로 켜진다.
---
## 4. 대시보드도 띄우려면
대시보드 (`hanarang-dashboard`) 는 별도 리포다. rails 가 돌아가고 있는 상태에서 같은 MariaDB 를 바라보도록 설정하면 `/rails` 페이지에서 파이프라인 트리가 시각화된다.
```bash
git clone https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard.git
cd hanarang-dashboard/backend
cp .env.example .env
# DATABASE_URL 을 rails 와 같게
# RAILS_API_URL=http://localhost:18800
# GIT_RAW_ALLOWED_HOSTS=git.example.com (optional, for MD viewer)
pnpm install && pnpm build && pnpm start:prod
```
프론트엔드는 별도 프로세스:
```bash
cd ../frontend
pnpm install && pnpm dev
# → http://localhost:3000/rails
```
---
## 5. 자주 막히는 부분
**Q. `pnpm rails run` 이 "DATABASE_URL not set" 에러.**
`.env` 가 rails 의 작업 디렉토리에 있어야 한다. `loadEnv()``process.cwd()` 기준으로 찾는다.
**Q. Mock 모드인데 LLM 응답이 텅 비어 있다.**
정상이다. Mock 은 결정론 스켈레톤만 확인하려고 있는 거라 파일도 안 만들고 내용도 거의 없다. 실제 LLM 로 바꿔야 의미 있는 산출물이 나온다.
**Q. In-process 모드인데 `sister-agent core module not found`.**
`sister-agent/dist/core.js` 가 빌드되지 않은 상태다. `cd sister-agent && pnpm build`. 또는 `SISTER_AGENT_CORE_PATH` 로 절대 경로 명시.
**Q. OpenAI 대신 OpenRouter / Azure OpenAI / 로컬 llama.cpp 서버를 쓸 수 있나?**
된다. `LLM_PROVIDER=openai` 로 두고 `OPENAI_BASE_URL` 을 바꿔 주면 OpenAI Chat Completions 프로토콜을 말하는 모든 서버에 붙는다.
**Q. 4 개 자매를 진짜 분리된 컨테이너로 돌리고 싶다.**
`docker-compose.full.yml` 을 써라. rails 1 개 + 각 자매 1 개씩 총 6 개 서비스가 뜨고, rails 가 HTTP 로 각 자매에게 invoke 를 보낸다.
---
## 6. 다음 단계
- **구조 전체를 이해하고 싶다면**: [`docs/GUIDE.md`](GUIDE.md) 또는 PDF 버전
- **실제로 코드를 건드리고 싶다면**: [`.plans/design/`](../.plans/design/) 의 설계 문서
- **프롬프트/역할을 본인 도메인에 맞추고 싶다면**: `sister-agent/src/prompts.ts`, `sister-agent/src/roles.ts`, `rails.config.local.yaml` 순으로 읽기

198
docs/discord-setup.md Normal file
View File

@@ -0,0 +1,198 @@
# Discord Setup
> How to wire `hanarang-rails` to a Discord guild for the real DiscordTransport.
## Overview
Rails splits transport and observation:
- **Transport (deterministic)**: Rails posts marker blocks with structured invoke data. Agent bots parse the markers directly (not through LLM).
- **Observation (natural language)**: Agent bots keep posting free-form messages for human readers. Rails ignores the free-form text.
## Bot accounts
Two kinds of discord bots are involved:
1. **Rails bot** — posts invoke markers, state transitions, escalations.
2. **Agent bots (one per role, optional)** — each agent/sister has its own bot persona that responds with result markers and natural-language commentary.
If you don't need per-role personas, you can run a single bot for both rails and all agents.
## Rails bot setup
1. Go to https://discord.com/developers/applications
2. Create a new application → bot user
3. Enable privileged intents: **Message Content Intent** must be on.
4. OAuth2 URL generator → scopes: `bot`, permissions: `Send Messages`, `Read Message History`, `Create Public Threads`, `Manage Messages` (for marker cleanup, optional).
5. Invite the bot to your guild.
6. Copy the token.
Set in `.env`:
```
RAILS_DISCORD_TOKEN=<token>
DISCORD_GUILD_ID=<guild-id>
DISCORD_PIPELINE_CHANNEL_ID=<channel-id>
```
## Agent bot integration
Each agent host needs a minimal message handler that recognizes rails markers and routes them out of the LLM path:
```ts
import { DiscordPoster } from "hanarang-rails";
client.on("messageCreate", async (msg) => {
const invokeMarker = "<!-- rails:invoke v1 -->";
if (msg.content.includes(invokeMarker)) {
// Structured mode — do NOT send to the LLM
const req = extractJsonBlock(msg.content, "rails:invoke");
const result = await runRailsTask(req); // your agent's task runner
const resultMarker =
"<!-- rails:result v1 -->\n```json\n" +
JSON.stringify(result) +
"\n```\n<!-- /rails:result -->";
await msg.channel.send(
resultMarker +
"\n\n(Agent natural-language commentary here, optional)"
);
return;
}
// Otherwise: existing free-form conversation path
await runFreeFormLlm(msg);
});
```
### Marker format
**Invoke** (rails → agent):
```
<!-- rails:invoke v1 -->
```json
{
"pipelineId": "01HW0...",
"contractId": "01HW1...",
"stage": "implement",
"role": "implement",
"sprintId": "SPRINT-007",
"task": {
"title": "Add feature X",
"description": "...",
"workdir": "/path/to/workdir"
},
"timeoutMs": 30000,
"structuredOutput": true
}
```
<!-- /rails:invoke -->
```
**Result** (agent → rails):
```
<!-- rails:result v1 -->
```json
{
"stage": "implement",
"verdict": "IMPL_DONE",
"payload": {
"branch": "feature/sprint-007",
"commits": ["abc1234"],
"workdir": "...",
"selfTestReport": {"typecheck": "pass"}
},
"errorReason": ""
}
```
<!-- /rails:result -->
구현 완료했어요! 테스트 전부 통과했습니다 ❤️
```
The natural-language tail after `/rails:result` is free-form — rails ignores it, but the human user sees it.
## DiscordPoster interface
To wire rails to a real discord.js client, implement `DiscordPoster` and pass it when constructing `DiscordTransport`:
```ts
import { Client, GatewayIntentBits, TextChannel } from "discord.js";
import { DiscordTransport, type DiscordPoster } from "hanarang-rails";
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
await client.login(process.env.RAILS_DISCORD_TOKEN);
const poster: DiscordPoster = {
async postMessage(channelId, content) {
const channel = await client.channels.fetch(channelId);
if (!channel?.isTextBased()) throw new Error("Not a text channel");
const msg = await (channel as TextChannel).send(content);
return msg.id;
},
async waitForResult({ channelId, pipelineId, stage, timeoutMs, signal }) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(new Error("aborted"));
});
const handler = (msg: any) => {
if (msg.channel.id !== channelId) return;
const body = msg.content as string;
if (!body.includes("<!-- rails:result v1 -->")) return;
if (!body.includes(pipelineId)) return;
if (!body.includes(`"stage":"${stage}"`)) return;
clearTimeout(timer);
client.off("messageCreate", handler);
resolve(body);
};
client.on("messageCreate", handler);
});
},
async close() {
await client.destroy();
},
};
const transport = new DiscordTransport({
token: process.env.RAILS_DISCORD_TOKEN!,
guildId: process.env.DISCORD_GUILD_ID!,
channelId: process.env.DISCORD_PIPELINE_CHANNEL_ID!,
poster,
});
```
## Per-pipeline threads
For cleanness, create a forum thread per pipeline:
```ts
// On state transition, create a thread under the pipeline channel
const thread = await (channel as TextChannel).threads.create({
name: `[SPRINT-007] ${projectName}`,
autoArchiveDuration: 1440,
});
```
Pass `thread.id` as `channelId` when invoking agents. Rails stores the pipeline → thread mapping in SQLite (`pipelines.contextJson`).
## Testing without a live bot
For development, use `rails run --mock` — the `MockTransport` doesn't touch discord and returns deterministic success messages. All tests ship with a fake poster; no real tokens needed.
## Security
- **Never commit tokens.** `.env` is gitignored. Use secrets manager for production.
- **Validate HMAC** on any inbound webhooks (Gitea). See `GITEA_WEBHOOK_SECRET`.
- **Rate limit guard**: rails retries on 429 with exponential backoff (Sprint 005).
## Related
- `operations.md` — day-to-day ops
- `migration-guide.md` — porting from legacy bridges
- `.plans/design/transports.md` — transport abstraction design

180
docs/migration-guide.md Normal file
View File

@@ -0,0 +1,180 @@
# Migration Guide
> How to move from an existing agent pipeline (e.g., a Lobster-based `hanarang-harness` install) to `hanarang-rails`.
## Summary
`hanarang-rails` replaces the legacy "권고 기반" pipeline with a **deterministic, contract-enforced** one. The migration is safe: nothing in the archive is deleted, and rails can run side-by-side until you cut over.
## Before you start
- **Back up the old install.** Keep the archive read-only; don't delete it.
- **Install rails on a neutral host** — ideally the same machine that holds the SSOT repository, not one of the agent workers.
- **Confirm Node 22+ and pnpm are available** via `rails doctor`.
## Step 0 — Install rails
```bash
bash install.sh --dir /path/to/hanarang-rails --repo <your-rails-repo-url>
cd /path/to/hanarang-rails
cp .env.example .env # fill DATABASE_URL, DISCORD_TOKEN, etc.
pnpm prisma migrate deploy
pnpm rails doctor
```
## Step 1 — Scan the archive
```bash
rails migrate from-hanarang-harness /path/to/hanarang-harness-archive
```
This reports:
- Agents (md files) — candidates to port
- Scripts — portable vs deprecated (bridge.sh is deprecated)
- Workflows — Lobster files are flagged as deprecated
- Warnings — e.g., any `xhigh` thinking tier reference (forbidden)
No files are modified. Review the report and decide.
## Step 2 — Bring over agent definitions
Copy the agent markdown files you want to keep into the rails `agents/` directory. Rails does not prescribe a naming scheme; `rails.config.yaml` maps **stage****agent**, so you can keep role-specific personas.
```bash
mkdir -p agents/
cp /path/to/archive/agents/*.md agents/
```
Review each file and remove anything that references:
- `xhigh` thinking tier (forbidden in rails — causes infinite waits)
- Mention-based handoff instructions
- Direct discord bot behavior (rails now posts on their behalf)
## Step 3 — Port scaffolding
The legacy `scaffold.sh` is now `rails scaffold`:
```bash
rails scaffold /path/to/new-project --name my-project
```
This creates `.plans/` with the standard directory structure (`design/`, `sprints/`, `migration/`) plus a root `Plans.md`.
## Step 4 — Wire `rails.config.yaml`
```yaml
pipeline:
stages: [plan, implement, review, deploy]
agents:
plan:
role: plan
displayName: Planner
transport: discord
channelId: ${PLAN_CHANNEL}
timeoutMs: 30000
implement:
role: implement
displayName: Generator
transport: discord
channelId: ${IMPL_CHANNEL}
timeoutMs: 60000
review:
role: review
displayName: Evaluator
transport: discord
channelId: ${REVIEW_CHANNEL}
timeoutMs: 30000
deploy:
role: deploy
displayName: Deploy
transport: local
timeoutMs: 60000
discord:
enabled: true
railsToken: ${RAILS_DISCORD_TOKEN}
guildId: ${DISCORD_GUILD_ID}
pipelineChannelId: ${PIPELINE_THREAD_PARENT}
```
All secrets live in `.env`. The config file uses `${VAR}` interpolation — no token bytes in the repo.
## Step 5 — Update agent runtimes
Each agent host (e.g., a sister container) needs one change to its message handler:
```js
// Pseudocode — plug into your agent's discord event handler
onDiscordMessage(msg) {
if (msg.content.includes("<!-- rails:invoke v1 -->")) {
const req = extractJsonBlock(msg.content, "rails:invoke");
// Structured pipeline mode — LLM 우회
const result = await handleRailsInvoke(req);
await postResultMarker(msg.channel, result);
return;
}
// Otherwise: existing free-form conversation path
llmRespond(msg);
}
```
See `docs/discord-setup.md` for the full marker format.
## Step 6 — Cutover
1. **Smoke test with mock transport** first:
```bash
rails run --mock test-project -r "hello world"
rails status
```
This exercises the full FSM without touching real agents.
2. **Switch one stage at a time to discord**:
```yaml
agents:
plan:
transport: discord # flip this first
implement:
transport: mock # keep others on mock until plan is green
```
3. **Monitor escalations**:
```bash
rails status <pipeline-id>
```
Any unexpected escalation triggers discord alert (if configured).
4. **Disable legacy mention-based handoff** on the old agents once all stages run through rails.
## Step 7 — Decommission legacy bridge
After rails handles 100% of traffic:
1. Stop the old `bridge.sh` process(es).
2. Keep the archive as read-only reference.
3. Remove any cron jobs or systemd units that referenced the old install.
## Rollback
If rails fails badly:
```bash
rails abort <pipeline-id> # stop the misbehaving pipeline
pm2 stop hanarang-rails # stop the orchestrator
# Start the legacy bridge again if it's still present
```
The SSOT repository is untouched — both systems write to the same Gitea.
## Known limitations (v0.1.0)
- **Discord bot wiring requires an operator task.** Rails ships the `DiscordTransport` class but does not auto-connect discord.js; you plug in a client via the `DiscordPoster` interface. A default implementation will ship in v0.2.0.
- **Gitea webhook receiver** is scaffolded but not yet exposed as an HTTP endpoint in `rails serve` — tracked for v0.2.0.
- **Manual QA checks** are stubbed (SKIPPED by default). Provide a `manualResolver` to `runQaTemplate` to wire up a reviewer LLM.
## Further reading
- [`.plans/failure-audit.md`](../.plans/failure-audit.md) — why rails exists (F1F6)
- [`.plans/design/`](../.plans/design/) — architecture docs
- `docs/operations.md` — day-to-day operations guide

185
docs/operations.md Normal file
View File

@@ -0,0 +1,185 @@
# Operations Guide
> Day-to-day operations for running `hanarang-rails` in production.
## Process management
Rails is a long-lived orchestrator. Use `pm2` (recommended), `systemd`, or `docker-compose` to supervise it.
### PM2
```bash
cd /path/to/hanarang-rails
pm2 start ecosystem.config.cjs
pm2 save
pm2 startup # enable auto-start on reboot
```
Check status:
```bash
pm2 list
pm2 logs hanarang-rails
pm2 restart hanarang-rails
```
## Health check
Run this on a cron or uptime monitor:
```bash
pnpm rails doctor
```
Exit code 0 = healthy, 1 = one or more errors.
For deeper state:
```bash
pnpm rails status # list recent pipelines
pnpm rails status <pipeline> # single pipeline with timeline
```
## Common tasks
### Start a pipeline from the shell
```bash
pnpm rails run my-project -r "Add Live2D avatar component"
```
### Resume an escalated pipeline
```bash
pnpm rails status --state escalated
pnpm rails resume <pipeline-id>
```
### Abort a runaway pipeline
```bash
pnpm rails abort <pipeline-id> -r "wrong branch"
```
### Generate and freeze a contract
```bash
pnpm rails contract generate .plans/sprints/SPRINT-007.md -s SPRINT-007
# review the draft in .rails/contracts/<id>.sprint-contract.json
pnpm rails contract freeze <id>
pnpm rails contract validate <id>
```
### Run QA for a sprint type
```bash
pnpm rails qa run feature -s SPRINT-007
pnpm rails qa show <artifact-id>
```
## Troubleshooting
### "No skill context found"
The enforcement hook is blocking tool calls because the rails skill context is missing or expired.
```bash
pnpm rails skill-context create --pipeline <id> --skill rails
pnpm rails skill-context show
```
To temporarily disable enforcement for debugging (logged to trace):
```bash
RAILS_ENFORCE=off pnpm rails run ...
```
### Pipeline stuck in `retrying`
The retrying state has an `always` transition — it should move forward immediately. If you see it stuck in SQL dumps, check for a stale process holding a DB connection. Restart the orchestrator:
```bash
pm2 restart hanarang-rails
```
### "Contract validator ABORT_PRECHECK"
Environment prerequisites failed. The validator output will name the missing prereq. Common causes:
- `node22` prereq → upgrade Node runtime
- `DATABASE_URL` env var missing → check `.env`
- `port_open` → the target service is down
- `http_reachable` → network / firewall
### xhigh thinking tier refused
Rails refuses to pass `xhigh` to agents because it caused indefinite waits in the legacy system. Use `high` or below. If an agent config still sets `xhigh`, grep and update:
```bash
grep -rn "thinking_tier.*xhigh" agents/
```
### Manual QA checks always SKIPPED
By default, `rails qa run` marks manual checks as SKIPPED (passed=true with a skip note). To actually evaluate, plug in a resolver programmatically. A shipped LLM resolver is tracked for v0.2.0.
## Logs
Rails uses `pino` for structured logging. Every log line is JSON with at least:
```json
{"level":30,"time":...,"service":"hanarang-rails","module":"runner","pipelineId":"01..."}
```
Pipe through `pino-pretty` for interactive reading:
```bash
pm2 logs hanarang-rails --raw | pino-pretty
```
## Database maintenance
Rails uses a single MariaDB schema with 5 tables: `pipelines`, `state_transitions`, `actor_spawns`, `contracts`, `escalations`.
### Retention
By default there is no automatic retention. Add a cron:
```sql
-- Trim state_transitions older than 90 days for terminal pipelines
DELETE st FROM state_transitions st
JOIN pipelines p ON p.id = st.pipelineId
WHERE p.currentState IN ('done', 'aborted')
AND p.updatedAt < NOW() - INTERVAL 90 DAY;
```
### Backup
Standard MariaDB dump:
```bash
mysqldump hanarang_rails > backup-$(date +%F).sql
```
## Security
- **Never commit `.env`.** Use secret management for production deployments.
- **Rotate `DISCORD_TOKEN` periodically.** Rails reads env vars on startup.
- **`GITEA_WEBHOOK_SECRET`** must be a high-entropy random string — used for HMAC verification.
- **Skill enforcement trace** at `.rails/skill-trace.jsonl` may contain tool usage history. Rotate/truncate on long-lived installs.
## Versioning
Rails follows semver. Check current version:
```bash
pnpm rails --help | head -1
```
Upgrade:
```bash
git pull
pnpm install --prod
pnpm build
pnpm prisma migrate deploy
pm2 restart hanarang-rails
```
## Related
- `migration-guide.md` — moving from legacy installs
- `.plans/design/` — architecture
- `.plans/failure-audit.md` — F1F6 that rails prevents

View File

@@ -20,6 +20,7 @@ model Pipeline {
actorSpawns ActorSpawn[]
contracts Contract[]
escalations Escalation[]
subTasks SubTask[]
@@index([currentState])
@@index([createdAt])
@@ -74,6 +75,49 @@ model Contract {
@@map("contracts")
}
model SubTask {
id String @id @db.VarChar(26)
pipelineId String @db.VarChar(26)
parentId String? @db.VarChar(26)
role String @db.VarChar(30)
agentName String @db.VarChar(50)
title String @db.VarChar(500)
description String @db.Text
state String @db.VarChar(30) @default("queued")
complexityScore Int?
complexityTier String? @db.VarChar(20)
model String @db.VarChar(50) @default("")
resultJson String? @db.LongText
errorReason String? @db.Text
startedAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
parent SubTask? @relation("SubTaskHierarchy", fields: [parentId], references: [id], onDelete: NoAction, onUpdate: NoAction)
children SubTask[] @relation("SubTaskHierarchy")
events SubTaskEvent[]
@@index([pipelineId, parentId])
@@index([state])
@@index([agentName, state])
@@map("sub_tasks")
}
model SubTaskEvent {
id Int @id @default(autoincrement())
subTaskId String @db.VarChar(26)
eventType String @db.VarChar(50)
payloadJson String @db.LongText
timestamp DateTime @default(now())
subTask SubTask @relation(fields: [subTaskId], references: [id], onDelete: Cascade)
@@index([subTaskId, timestamp])
@@index([eventType])
@@map("sub_task_events")
}
model Escalation {
id String @id @db.VarChar(26) // ULID
pipelineId String @db.VarChar(26)

View File

@@ -0,0 +1,50 @@
template: bugfix-v1
version: v1
appliesTo: [bugfix]
requiredChecks:
- id: regression-test
description: 버그를 재현하는 테스트가 추가됨 (수정 전 fail → 수정 후 pass)
kind: manual
spec:
question: 새로 추가된 regression 테스트가 있는가?
guidance: 수정 전 커밋에서 테스트가 실패하는지 확인했는가?
blocking: true
severity: major
- id: root-cause-documented
description: root cause 기록
kind: manual
spec:
question: 스프린트 문서 또는 커밋 메시지에 root cause 가 명시되었는가?
blocking: true
severity: major
- id: no-scope-creep
description: 버그 외 리팩터/기능 추가 없음
kind: manual
spec:
question: 이번 커밋이 오직 해당 버그만 수정하는가?
guidance: 동반 리팩터/포매팅 변경은 별도 커밋으로 분리되어야 함.
blocking: true
severity: major
- id: tests-pass
description: 전체 테스트 pass
kind: command_success
spec:
command: pnpm test
timeoutMs: 120000
expectExitCode: 0
blocking: true
severity: major
- id: typecheck
description: 타입 체크 pass
kind: command_success
spec:
command: pnpm tsc --noEmit
timeoutMs: 60000
expectExitCode: 0
blocking: true
severity: major

View File

@@ -0,0 +1,66 @@
template: feature-v1
version: v1
appliesTo: [feature]
requiredChecks:
- id: tests-pass
description: 전체 테스트 통과
kind: command_success
spec:
command: pnpm test
timeoutMs: 120000
expectExitCode: 0
blocking: true
severity: major
- id: typecheck
description: TypeScript 타입 체크 통과
kind: command_success
spec:
command: pnpm tsc --noEmit
timeoutMs: 60000
expectExitCode: 0
blocking: true
severity: major
- id: no-console-log
description: console.* 호출 없음 (pino 사용)
kind: manual
spec:
question: 모든 새 코드가 pino logger 를 사용하고 console.* 직접 호출이 없는가?
guidance: grep -rn 'console\.' src/ 로 확인. 테스트 코드는 예외.
blocking: true
severity: major
- id: no-any-type
description: any 타입 신규 도입 없음
kind: manual
spec:
question: Zod 경계 밖에서 any 타입이 도입되지 않았는가?
guidance: 외부 입력은 Zod 검증 후 타입이 확정됨. any 는 절대 금지.
blocking: true
severity: major
- id: tests-added
description: 새 기능에 대한 테스트가 추가됨
kind: manual
spec:
question: 이번 변경 사항에 대한 단위/통합 테스트가 최소 1개 추가되었는가?
blocking: true
severity: major
- id: error-handling
description: 주요 에러 경로에 Result / try-catch 적용
kind: manual
spec:
question: 외부 시스템 호출 (네트워크, DB, subprocess) 에러가 적절히 처리되는가?
blocking: false
severity: minor
- id: docs-updated
description: README / .plans 에 변경 반영
kind: manual
spec:
question: 사용자 관찰 가능한 변경 사항이 README 또는 .plans 에 반영되었는가?
blocking: false
severity: minor

View File

@@ -0,0 +1,46 @@
template: infra-v1
version: v1
appliesTo: [infra, deploy-only]
requiredChecks:
- id: config-validated
description: 인프라 설정 파일이 유효한지 확인
kind: manual
spec:
question: 변경된 설정 파일이 파싱/검증을 통과했는가?
guidance: docker-compose config, nginx -t, terraform validate 등.
blocking: true
severity: critical
- id: secrets-not-leaked
description: 시크릿이 리포지토리에 누출되지 않음
kind: manual
spec:
question: 새로 추가된 파일에 토큰/비밀번호가 포함되지 않았는가?
guidance: git diff 로 확인. .env 류는 예제만 commit.
blocking: true
severity: critical
- id: backward-compatible
description: 기존 서비스 호환
kind: manual
spec:
question: 기존에 돌던 서비스가 계속 동작하는가?
blocking: true
severity: major
- id: rollback-documented
description: 롤백 절차 문서화
kind: manual
spec:
question: 배포 실패 시 복구 절차가 명확한가?
blocking: true
severity: major
- id: health-check
description: 배포 후 health check 정의
kind: manual
spec:
question: 배포 성공 여부를 자동 판정할 수 있는 health check 가 있는가?
blocking: false
severity: major

View File

@@ -0,0 +1,53 @@
template: migration-v1
version: v1
appliesTo: [migration]
requiredChecks:
- id: migration-script-exists
description: 마이그레이션 스크립트 파일 존재
kind: manual
spec:
question: prisma/migrations, SQL, 또는 해당 마이그레이션 스크립트가 존재하는가?
blocking: true
severity: critical
- id: rollback-plan
description: 롤백 계획 문서화
kind: manual
spec:
question: 롤백 절차가 .plans 또는 커밋 메시지에 문서화되었는가?
blocking: true
severity: critical
- id: dry-run-tested
description: dry-run 검증 완료
kind: manual
spec:
question: 프로덕션 전 stage/dry-run 환경에서 검증되었는가?
blocking: true
severity: critical
- id: data-loss-assessment
description: 데이터 손실 가능성 평가
kind: manual
spec:
question: 데이터 손실 리스크가 평가되었고 완화책이 있는가?
guidance: DROP / ALTER / NULL 전환 등은 반드시 평가.
blocking: true
severity: critical
- id: backup-captured
description: 운영 DB 백업 확인
kind: manual
spec:
question: 실행 직전 백업이 생성되었음을 확인했는가?
blocking: true
severity: critical
- id: idempotent
description: 재실행 안전성
kind: manual
spec:
question: 마이그레이션이 중단 후 재실행에도 안전한가?
blocking: false
severity: major

View File

@@ -0,0 +1,50 @@
template: refactor-v1
version: v1
appliesTo: [refactor]
requiredChecks:
- id: tests-pass
description: 리팩터 후 모든 테스트 pass
kind: command_success
spec:
command: pnpm test
timeoutMs: 120000
expectExitCode: 0
blocking: true
severity: major
- id: typecheck
description: 타입 체크 pass
kind: command_success
spec:
command: pnpm tsc --noEmit
timeoutMs: 60000
expectExitCode: 0
blocking: true
severity: major
- id: no-behavior-change
description: 외부 동작 변경 없음 (순수 리팩터)
kind: manual
spec:
question: 사용자 관찰 가능한 동작이 변경되지 않았는가?
guidance: 만약 변경되었다면 feature 로 재분류되어야 함.
blocking: true
severity: major
- id: tests-still-cover
description: 기존 테스트 커버리지 유지
kind: manual
spec:
question: 리팩터로 인해 테스트가 삭제되거나 우회되지 않았는가?
blocking: true
severity: major
- id: public-api-compatible
description: 공개 API 하위 호환
kind: manual
spec:
question: 공개 export 의 시그니처가 변경되지 않았는가?
guidance: 변경되었다면 breaking-change flag 필요.
blocking: false
severity: minor

View File

@@ -0,0 +1,54 @@
template: scaffold-v1
version: v1
appliesTo: [scaffold]
requiredChecks:
- id: readme-exists
description: README.md 가 존재하고 최소 내용 포함
kind: file_exists
spec:
path: README.md
blocking: true
severity: major
- id: license-exists
description: LICENSE 파일 존재
kind: file_exists
spec:
path: LICENSE
blocking: true
severity: minor
- id: gitignore-exists
description: .gitignore 존재
kind: file_exists
spec:
path: .gitignore
blocking: true
severity: major
- id: package-manager-lockfile
description: pnpm-lock.yaml 존재 (npm/yarn lock 금지)
kind: file_exists
spec:
path: pnpm-lock.yaml
blocking: true
severity: major
- id: no-npm-lock
description: package-lock.json 이 없어야 함 (pnpm 전용)
kind: manual
spec:
question: package-lock.json 이 존재하지 않습니까?
guidance: pnpm-lock.yaml 만 사용. package-lock.json 이 있으면 실패.
blocking: true
severity: major
- id: tsconfig-strict
description: tsconfig.json strict 모드
kind: regex_in_file
spec:
path: tsconfig.json
pattern: '"strict"\s*:\s*true'
blocking: true
severity: major

View File

@@ -0,0 +1,58 @@
# ────────────────────────────────────────────────────────────────
# rails.config.distributed.yaml
#
# Production-style topology. Each sister-agent runs as its own daemon
# (typically on its own host/container/LXC) and rails calls them over
# HTTP. This is what the hanarang internal deployment uses.
#
# Usage:
# cp rails.config.distributed.yaml rails.config.yaml
# # Start 4 sister-agent daemons (see docs/LOCAL-SETUP.md)
# pnpm rails serve -c rails.config.yaml
#
# Endpoints can also be overridden via env:
# SISTER_ENDPOINT_PLAN=http://host:18801 etc.
# ────────────────────────────────────────────────────────────────
pipeline:
stages:
- plan
- implement
- review
- deploy
agents:
plan:
role: plan
displayName: Planner
agentName: harang
transport: http
endpoint: http://harang.local:18801
timeoutMs: 600000
implement:
role: implement
displayName: Generator
agentName: narang
transport: http
endpoint: http://narang.local:18801
timeoutMs: 600000
review:
role: review
displayName: Evaluator
agentName: darang
transport: http
endpoint: http://darang.local:18801
timeoutMs: 600000
deploy:
role: deploy
displayName: Deploy
agentName: erang
transport: http
endpoint: http://erang.local:18801
timeoutMs: 600000
discord:
enabled: false

56
rails.config.local.yaml Normal file
View File

@@ -0,0 +1,56 @@
# ────────────────────────────────────────────────────────────────
# rails.config.local.yaml
#
# Single-host / single-process configuration. Every stage runs the
# sister-agent core directly inside the rails Node process — no separate
# daemons, no networking between agents, just one binary.
#
# This is the fastest way to try rails on your laptop.
#
# Usage:
# cp rails.config.local.yaml rails.config.yaml
# cp .env.example .env # then set LLM_PROVIDER + any API keys
# pnpm rails serve -c rails.config.yaml
#
# Env vars (RAILS_TRANSPORT, SISTER_ENDPOINT_*, etc.) always override
# whatever is in this file.
# ────────────────────────────────────────────────────────────────
pipeline:
stages:
- plan
- implement
- review
- deploy
agents:
plan:
role: plan
displayName: Planner
agentName: harang
transport: in-process
timeoutMs: 600000
implement:
role: implement
displayName: Generator
agentName: narang
transport: in-process
timeoutMs: 600000
review:
role: review
displayName: Evaluator
agentName: darang
transport: in-process
timeoutMs: 600000
deploy:
role: deploy
displayName: Deploy
agentName: erang
transport: in-process
timeoutMs: 600000
discord:
enabled: false

View File

@@ -0,0 +1 @@
1775820498

View File

View File

@@ -0,0 +1,7 @@
{
"timestamp": "2026-04-10T11:32:37Z",
"changed_file": "src/spawn.ts",
"test_command": "npm test",
"related_test": "",
"recommendation": "テストの実行を推奨します"
}

View File

@@ -0,0 +1 @@
1 1775808904

26
sister-agent/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "sister-agent",
"version": "0.1.0",
"description": "Sub-agent orchestrator daemon running on each sister LXC",
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsc --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"ulid": "^2.3.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.8.0",
"vitest": "^3.1.0"
},
"packageManager": "pnpm@9.15.0"
}

1029
sister-agent/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,177 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join, normalize, sep } from "node:path";
export interface ExtractedFile {
path: string; // normalized relative path (e.g. "src/index.html")
lang: string; // language tag from the fence
content: string; // file contents
absPath?: string; // populated after write
}
const LANG_TO_EXT: Record<string, string> = {
html: "html",
htm: "html",
xml: "xml",
svg: "svg",
css: "css",
scss: "scss",
sass: "sass",
javascript: "js",
js: "js",
jsx: "jsx",
typescript: "ts",
ts: "ts",
tsx: "tsx",
json: "json",
yaml: "yaml",
yml: "yaml",
toml: "toml",
ini: "ini",
python: "py",
py: "py",
ruby: "rb",
rb: "rb",
rust: "rs",
rs: "rs",
go: "go",
java: "java",
kotlin: "kt",
kt: "kt",
swift: "swift",
c: "c",
"c++": "cpp",
cpp: "cpp",
cxx: "cpp",
cs: "cs",
csharp: "cs",
php: "php",
sh: "sh",
bash: "sh",
shell: "sh",
zsh: "sh",
fish: "fish",
sql: "sql",
markdown: "md",
md: "md",
dockerfile: "dockerfile",
makefile: "mk",
prisma: "prisma",
graphql: "graphql",
env: "env",
};
/**
* Parse markdown code fences out of an LLM response.
*
* Supported fence header forms:
* ```html
* ```html:index.html
* ```html path=src/index.html
* ```src/index.html (no lang, filename only)
* ```ts title=src/main.ts
*/
export function extractCodeBlocks(text: string): ExtractedFile[] {
const files: ExtractedFile[] = [];
const re = /```([^\n`]*)\n([\s\S]*?)\n```/g;
let match: RegExpExecArray | null;
let anonCounter = 0;
while ((match = re.exec(text)) !== null) {
const header = (match[1] ?? "").trim();
const content = match[2] ?? "";
const parsed = parseHeader(header);
if (!parsed) continue;
let path = parsed.path;
if (!path) {
anonCounter += 1;
const ext = LANG_TO_EXT[parsed.lang] ?? "txt";
path = `block-${String(anonCounter).padStart(2, "0")}.${ext}`;
}
// Normalize and sanitize path — strip leading /, resolve ., block ..
const cleanPath = sanitizePath(path);
if (!cleanPath) continue;
files.push({ path: cleanPath, lang: parsed.lang, content });
}
return files;
}
function parseHeader(header: string): { lang: string; path: string } | null {
if (header.length === 0) return null;
// form: "html:src/index.html"
const colonIdx = header.indexOf(":");
if (colonIdx > 0 && !header.slice(0, colonIdx).includes(" ")) {
const lang = header.slice(0, colonIdx).toLowerCase();
const rest = header.slice(colonIdx + 1).trim();
if (looksLikePath(rest)) {
return { lang, path: rest };
}
}
// form: "html path=src/index.html" or "ts title=src/main.ts"
const kvMatch = header.match(/^(\w+)\s+(?:path|title|file)=(\S+)/i);
if (kvMatch) {
return { lang: kvMatch[1]!.toLowerCase(), path: kvMatch[2]! };
}
// form: "src/index.html" (path only, no lang)
if (looksLikePath(header) && !/^\w+$/.test(header)) {
const ext = header.split(".").pop()?.toLowerCase() ?? "";
return { lang: ext, path: header };
}
// form: "html" (bare lang, no path)
const lang = header.split(/\s+/)[0]?.toLowerCase() ?? "";
if (lang.length === 0) return null;
return { lang, path: "" };
}
function looksLikePath(s: string): boolean {
if (s.length === 0) return false;
if (s.includes(" ")) return false;
// Has an extension OR a slash
return /\.[a-z0-9]{1,6}$/i.test(s) || s.includes("/");
}
function sanitizePath(p: string): string | null {
const normalized = normalize(p).replace(/^(?:\.\.(?:\/|\\))+/, "");
if (
normalized.startsWith(sep) ||
normalized.startsWith("/") ||
normalized.includes("..")
) {
return null;
}
return normalized;
}
/**
* Save extracted files to the given directory under a `files/` subdir.
* Returns the same list with absPath populated.
*/
export async function saveExtractedFiles(
baseDir: string,
files: ExtractedFile[],
): Promise<ExtractedFile[]> {
if (files.length === 0) return files;
const targetRoot = join(baseDir, "files");
await mkdir(targetRoot, { recursive: true });
const saved: ExtractedFile[] = [];
for (const f of files) {
const absPath = join(targetRoot, f.path);
try {
await mkdir(dirname(absPath), { recursive: true });
await writeFile(absPath, f.content, "utf8");
saved.push({ ...f, absPath });
} catch {
// skip — best effort
}
}
return saved;
}

View File

@@ -0,0 +1,173 @@
import { z } from "zod";
export const ComplexityTier = z.enum([
"trivial",
"simple",
"moderate",
"complex",
"massive",
]);
export type ComplexityTier = z.infer<typeof ComplexityTier>;
export interface ComplexityScore {
score: number; // 0-100
tier: ComplexityTier;
factors: {
scopeScale: number;
multiDomain: number;
riskKeywords: number;
parallelismHints: number;
uncertainty: number;
estimatedLoc: number;
crossAgentDep: number;
};
matched: string[]; // matched keywords for transparency
}
const SCOPE_RULES: Array<{ re: RegExp; score: number; label: string }> = [
{ re: /한\s*줄|single\s*line|one\s*liner/i, score: 2, label: "one-liner" },
{ re: /single\s*file|한\s*파일|단일\s*파일/i, score: 5, label: "single-file" },
{ re: /컴포넌트|component|small\s*feature|작은\s*기능/i, score: 12, label: "small-feature" },
{ re: /여러\s*파일|multiple\s*files|multi-?file|멀티\s*모듈/i, score: 25, label: "multi-file" },
{ re: /sprint|스프린트/i, score: 40, label: "sprint-scale" },
{ re: /전체\s*리팩터|full\s*refactor|architecture|아키텍처/i, score: 65, label: "architecture" },
{ re: /from\s*scratch|새로\s*만들|scaffold|새\s*프로젝트|new\s*project/i, score: 75, label: "scaffold" },
{ re: /monorepo|migration\s*project|전면\s*재작성/i, score: 85, label: "massive" },
];
const DOMAINS = [
"frontend", "front-end", "프론트",
"backend", "back-end", "백엔드",
"database", "db", "prisma", "postgres", "mariadb", "mysql",
"infra", "deploy", "배포", "docker", "k8s", "kubernetes",
"ci", "cd", "github\\s*actions", "gitea",
"security", "auth", "인증", "oauth",
"test", "테스트", "vitest", "jest",
"api", "rest", "graphql",
];
const RISK_KEYWORDS = [
"migration", "migrate", "마이그레이션",
"breaking", "breaking\\s*change", "호환\\s*안", "하위\\s*호환",
"security", "vulnerability", "취약점",
"auth", "authentication", "authorization",
"data\\s*loss", "데이터\\s*손실", "rollback",
];
const PARALLELISM_HINTS = [
"multiple", "여러", "parallel", "병렬", "동시에", "simultaneously",
"bulk", "대량", "batch", "fanout",
];
const UNCERTAINTY_MARKERS = [
"probably", "maybe", "might", "I\\s*think",
"아직\\s*모르", "아마", "잘\\s*모르", "애매",
];
const LOC_HINT = /(\d{2,})\s*(?:lines?|loc|줄)/i;
const CROSS_AGENT_HINTS = [
/plan.*implement|implement.*review|review.*deploy/i,
/기획.*구현|구현.*리뷰|리뷰.*배포/i,
/전체\s*(?:파이프라인|flow|흐름)/i,
];
function countMatches(text: string, patterns: string[]): {
count: number;
matched: string[];
} {
const matched: string[] = [];
for (const p of patterns) {
const re = new RegExp(`\\b${p}\\b`, "i");
if (re.test(text)) matched.push(p);
}
return { count: matched.length, matched };
}
export function scoreComplexity(task: {
title: string;
description?: string;
}): ComplexityScore {
const text = `${task.title}\n${task.description ?? ""}`;
const matched: string[] = [];
// Scope scale — take the MAX matching rule
let scopeScale = 0;
for (const rule of SCOPE_RULES) {
if (rule.re.test(text)) {
if (rule.score > scopeScale) scopeScale = rule.score;
matched.push(`scope:${rule.label}`);
}
}
if (scopeScale === 0) scopeScale = 10; // unknown default
// Multi-domain
const { count: domainCount, matched: domainMatched } = countMatches(text, DOMAINS);
const multiDomain = Math.min(domainCount * 5, 20);
matched.push(...domainMatched.map((d) => `domain:${d}`));
// Risk keywords
const { count: riskCount, matched: riskMatched } = countMatches(text, RISK_KEYWORDS);
const riskKeywords = Math.min(riskCount * 10, 30);
matched.push(...riskMatched.map((r) => `risk:${r}`));
// Parallelism hints
const { count: parCount, matched: parMatched } = countMatches(text, PARALLELISM_HINTS);
const parallelismHints = Math.min(parCount * 5, 15);
matched.push(...parMatched.map((p) => `parallel:${p}`));
// Uncertainty
const { count: uncertainCount } = countMatches(text, UNCERTAINTY_MARKERS);
const uncertainty = uncertainCount > 0 ? 10 : 0;
if (uncertainty) matched.push("uncertainty");
// Estimated LOC
const locMatch = text.match(LOC_HINT);
const loc = locMatch && locMatch[1] ? parseInt(locMatch[1], 10) : 0;
const estimatedLoc = loc > 500 ? 10 : 0;
if (estimatedLoc) matched.push(`loc:${loc}`);
// Cross-agent dep
let crossAgentDep = 0;
for (const re of CROSS_AGENT_HINTS) {
if (re.test(text)) {
crossAgentDep = 10;
matched.push("cross-agent");
break;
}
}
const score = Math.min(
100,
scopeScale +
multiDomain +
riskKeywords +
parallelismHints +
uncertainty +
estimatedLoc +
crossAgentDep,
);
return {
score,
tier: tierFromScore(score),
factors: {
scopeScale,
multiDomain,
riskKeywords,
parallelismHints,
uncertainty,
estimatedLoc,
crossAgentDep,
},
matched,
};
}
function tierFromScore(score: number): ComplexityTier {
if (score <= 15) return "trivial";
if (score <= 30) return "simple";
if (score <= 50) return "moderate";
if (score <= 75) return "complex";
return "massive";
}

26
sister-agent/src/core.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* Sister-agent library entry — exposes the core execution functions so
* another process (e.g. rails running in single-process mode) can
* invoke them directly without going through HTTP.
*
* This lives in addition to ./server.ts (which wraps the same logic as
* an HTTP daemon). Both code paths share ./spawn.ts under the hood.
*/
export { executeInvocation } from "./spawn.js";
export { RailsClient } from "./rails-client.js";
export { createLlmAdapter, getLlmAdapter } from "./llm/index.js";
export type {
LlmAdapter,
LlmRequest,
LlmResult,
ProviderName,
} from "./llm/index.js";
export { ROLES, ROLE_KOREAN } from "./roles.js";
export type { RoleConfig } from "./roles.js";
export {
InvokeRequest,
HandoffMessage,
Role,
type SubTaskRecord,
} from "./types.js";

View File

@@ -0,0 +1,333 @@
import { spawn } from "node:child_process";
/**
* Send a Discord message via the local OpenClaw CLI.
*
* Each sister LXC has its own openclaw gateway logged in as a different
* Discord bot identity (하랑이 / 나랑이 / 다랑이 / 이랑이). When this is
* called from inside the sister-agent daemon running on that LXC, the
* message goes out as that sister's bot.
*
* Best-effort: any error is swallowed and logged to console.warn so that
* a Discord outage never blocks the actual rails pipeline.
*/
export async function notifyDiscord(opts: {
channelId: string;
message: string;
/** Path to openclaw CLI binary. Defaults to ~/.npm-global/bin/openclaw. */
bin?: string;
timeoutMs?: number;
}): Promise<{ ok: boolean; error?: string }> {
if (!opts.channelId) return { ok: false, error: "no channelId" };
if (!opts.message) return { ok: false, error: "empty message" };
const bin =
opts.bin ??
process.env["OPENCLAW_BIN"] ??
`${process.env["HOME"]}/.npm-global/bin/openclaw`;
const args = [
"message",
"send",
"--channel",
"discord",
"--target",
`channel:${opts.channelId}`,
"--message",
opts.message,
];
return new Promise((resolveFn) => {
const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
let stderr = "";
let settled = false;
// OpenClaw CLI cold-start (gateway connect + auth) can take 7-10s
// even on a healthy LXC. Use a generous timeout — this call is
// fire-and-forget from spawn.ts so a longer timer doesn't block the
// pipeline; it only matters if the openclaw process is genuinely
// stuck.
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill("SIGKILL");
resolveFn({ ok: false, error: `openclaw timeout` });
}, opts.timeoutMs ?? 25000);
child.stderr.on("data", (b: Buffer) => (stderr += b.toString()));
child.on("error", (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolveFn({ ok: false, error: `spawn error: ${err.message}` });
});
child.on("exit", (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code !== 0) {
resolveFn({
ok: false,
error: `openclaw exit ${code}: ${stderr.slice(0, 300)}`,
});
return;
}
resolveFn({ ok: true });
});
});
}
/**
* Per-sister persona message pools.
*
* Each sister has a personality (from .openclaw/workspace/SOUL.md):
* harang — planner / 차분하고 어른스러운 언니톤. 짧고 단정.
* narang — developer / 활달하고 손이 빠른 동생. 능률적, 약간 캐주얼.
* darang — qa / 꼼꼼하고 약간 까칠한, 정확함을 좋아하는.
* erang — infra / 차분하고 믿음직, 기술적이지만 부드러움.
*
* Pools have multiple variants so the channel doesn't feel robotic. We
* pick by hashing the pipeline title — same task always gets the same
* line, but different tasks rotate.
*/
export interface StageMessageContext {
agentName: string;
stage: "plan" | "implement" | "review" | "deploy";
taskTitle: string;
/**
* Verdict produced by the stage. Only used for stage-end messages —
* lets darang say "결함 발견" instead of "통과" when REQUEST_CHANGES,
* lets erang say "배포 실패" instead of "검증 완료" when DEPLOY_FAILED,
* etc. Stage-start ignores this field (verdict isn't known yet).
*/
verdict?: string;
childCount?: number;
filesProduced?: number;
/**
* Optional custom line provided by the LLM itself (extracted from a
* `discord-line` code block in the junior output). When set, this line
* is used verbatim instead of picking from the hardcoded pool. Falls
* back to the pool if empty / undefined.
*/
customLine?: string;
}
/**
* Pull every ```discord-line\n<line>\n``` block out of an LLM text blob.
* Returns the extracted lines AND the original text with all such blocks
* removed (so it can be safely passed to downstream stages without chat
* noise polluting their priorStages context).
*
* The block is intentionally a fenced code block so it doesn't conflict
* with regular markdown formatting and is easy for the LLM to emit
* verbatim.
*/
export function extractDiscordLines(text: string): {
lines: string[];
cleaned: string;
} {
if (!text) return { lines: [], cleaned: text };
// Match: ```discord-line<newline><single-line content><newline>```
const pattern = /```discord-line\s*\n([^\n`]*)\n```/g;
const lines: string[] = [];
let m: RegExpExecArray | null;
while ((m = pattern.exec(text)) !== null) {
const line = (m[1] ?? "").trim();
if (line) lines.push(line);
}
const cleaned = text
.replace(/```discord-line\s*\n[^\n`]*\n```/g, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
return { lines, cleaned };
}
const START_POOLS: Record<string, string[]> = {
harang: [
`📋 자, 기획 들어갈게. *{title}* 일단 범위부터 잡아둘게.`,
`📋 *{title}* — 어떤 게 MVP 안에 들어가야 할지 정리할게.`,
`📋 *{title}*, 통과 기준 먼저 정해놓고 갈게.`,
`📋 기획 시작 — *{title}*. 비범위도 명확히 박아둘게.`,
],
narang: [
`🔨 *{title}* 받았어! 바로 짜볼게.`,
`🔨 코드 작성 시작 — *{title}*. 후딱 만들어볼게.`,
`🔨 *{title}* 구현 들어간다. 파일 세팅부터.`,
`🔨 받았어 *{title}*. 손이 근질근질해.`,
],
darang: [
`🔍 *{title}* — 어디 어디 봐야 하나 체크리스트 뽑을게.`,
`🔍 리뷰 시작. *{title}* 한 줄씩 꼼꼼히 볼게.`,
`🔍 *{title}*, 통과 기준 항목별로 검사 들어갈게.`,
`🔍 *{title}* — 빠진 거 있나 보자.`,
],
erang: [
`🚀 *{title}* 배포 검증 시작. 환경부터 확인할게.`,
`🚀 *{title}*, 무리 없이 띄울 수 있는지 보고 올게.`,
`🚀 배포 단계 진입 — *{title}*. 안전하게 올려볼게.`,
`🚀 *{title}* 인프라 점검 들어갈게.`,
],
};
/**
* End-message pools are split by verdict where it matters.
*
* - harang: PLAN_READY (success) vs ABORT (give up)
* - narang: IMPL_DONE (success) vs ERROR (failure)
* - darang: APPROVE / REQUEST_CHANGES / ABORT
* - erang : DEPLOY_DONE / DEPLOY_FAILED
*
* The pool key is `${agent}/${verdict}`. If a verdict isn't recognised
* we fall back to the success pool (`${agent}/ok`).
*/
const END_POOLS: Record<string, string[]> = {
// ── 하랑이 ──
"harang/ok": [
`📋 기획 끝. 통과 기준 박아놨으니 나랑이 받아.`,
`📋 범위 잡혔어. 나랑아 부탁해.`,
`📋 정리 끝났어. 다음은 구현이야.`,
`📋 plan 완료. 나랑이가 받아갈 차례.`,
],
"harang/abort": [
`⚠️ 기획 중단할게 — 요구사항이 너무 모호해서 진행 못 해.`,
`⚠️ plan 단계에서 중단. 자기야 요구사항 다시 알려줘.`,
],
// ── 나랑이 ──
"narang/ok": [
`🔨 구현 끝났어{tail}. 다랑이 리뷰 부탁해.`,
`🔨 일단 다 박았어{tail}. 다랑아 봐줘.`,
`🔨 코드 정리 끝{tail}. 검수 넘긴다.`,
`🔨 implement 마무리{tail}. 다음은 review.`,
],
"narang/error": [
`❌ 구현 중 막혔어{tail}. 자기야 봐줄래?`,
`❌ implement 실패{tail}. 다음 단계 못 가.`,
],
// ── 다랑이 ──
"darang/approve": [
`🔍 리뷰 통과! 이랑이 받아.`,
`🔍 체크리스트 다 ✓. 배포로 넘길게.`,
`🔍 큰 문제 없어. 이랑아 배포 검증 부탁해.`,
`🔍 review 통과 — 다음은 이랑이.`,
],
"darang/request_changes": [
`⚠️ 결함 발견 — 나랑아 다시 봐줄래?`,
`⚠️ 통과 못 시켰어. 코드 다시 짜야 해.`,
`⚠️ 체크리스트 미달. 나랑아 수정 부탁해.`,
`⚠️ REQUEST_CHANGES — 한 번 더 돌려야겠어.`,
],
"darang/abort": [
`🛑 이건 접근 자체가 잘못된 것 같아. 중단.`,
`🛑 review 단계에서 abort — plan 부터 다시 봐야 해.`,
],
// ── 이랑이 ──
"erang/ok": [
`🚀 배포 검증 완료{tail}. 안전해.`,
`🚀 환경 점검 OK{tail}. 띄울 수 있어.`,
`🚀 deploy 끝{tail}. 자기야 확인해줘.`,
`🚀 검증 완료{tail}. 무리 없이 동작해.`,
],
"erang/failed": [
`❌ 배포 실패{tail} — 자기야 봐줘.`,
`❌ 환경 점검에서 막혔어{tail}. deploy 못 해.`,
],
};
/** Map (agentName, stage, verdict) → pool key. */
function endPoolKey(
agentName: string,
_stage: string,
verdict?: string,
): string {
const v = (verdict ?? "").toUpperCase();
switch (agentName) {
case "harang":
return v === "ABORT" ? "harang/abort" : "harang/ok";
case "narang":
return v === "ERROR" ? "narang/error" : "narang/ok";
case "darang":
if (v === "REQUEST_CHANGES") return "darang/request_changes";
if (v === "ABORT") return "darang/abort";
return "darang/approve";
case "erang":
return v === "DEPLOY_FAILED" ? "erang/failed" : "erang/ok";
default:
return `${agentName}/ok`;
}
}
/** Stable picker — same input gets same line. */
function pickFromPool(pool: string[], seed: string): string {
if (pool.length === 0) return "";
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = (hash * 31 + seed.charCodeAt(i)) | 0;
}
const idx = Math.abs(hash) % pool.length;
return pool[idx]!;
}
export function renderStageStart(ctx: StageMessageContext): string {
const title = ctx.taskTitle.slice(0, 80);
const pool = START_POOLS[ctx.agentName];
if (!pool) return `▶️ ${ctx.stage} 시작 — *${title}*`;
return pickFromPool(pool, ctx.agentName + ":start:" + title).replace(
"{title}",
title,
);
}
export function renderStageEnd(ctx: StageMessageContext): string {
const tail =
ctx.filesProduced && ctx.filesProduced > 0
? ` (산출물 ${ctx.filesProduced}개)`
: "";
// LLM-supplied custom line wins. The junior who actually did the work
// already knows what to say — use it verbatim. (We still substitute
// {tail} in case the LLM left the placeholder in.)
if (ctx.customLine && ctx.customLine.trim().length > 0) {
return ctx.customLine.trim().replace("{tail}", tail);
}
// Otherwise fall back to the hardcoded persona pool.
const key = endPoolKey(ctx.agentName, ctx.stage, ctx.verdict);
const pool = END_POOLS[key];
if (!pool) return `${ctx.stage} 완료${tail}`;
return pickFromPool(
pool,
ctx.agentName + ":end:" + key + ":" + ctx.taskTitle,
).replace("{tail}", tail);
}
/**
* Render an escalation alert. Used by the orchestrator when a pipeline
* exhausts retry / replan budget and needs the user to step in.
*/
export interface EscalationContext {
pipelineId: string;
projectName: string;
reason: string;
stage: string;
attempts: number;
/** Discord user ID to mention. If empty, no mention. */
mentionUserId?: string;
}
export function renderEscalation(ctx: EscalationContext): string {
const mention = ctx.mentionUserId ? `<@${ctx.mentionUserId}> ` : "";
const short = ctx.pipelineId.slice(0, 8);
return [
`${mention}🚨 **자기야, 막혔어** — 사람이 봐야 할 것 같아`,
``,
`**프로젝트:** ${ctx.projectName}`,
`**단계:** ${ctx.stage}`,
`**시도:** ${ctx.attempts}`,
`**사유:** ${ctx.reason.slice(0, 600)}`,
``,
`Pipeline ID: \`${ctx.pipelineId}\``,
`대시보드: https://hanarang.nabomhalang.co.kr/rails`,
``,
`복구하려면:`,
`\`bash ~/.openclaw/skills/hanarang-rails/scripts/rails-status.sh ${short}\``,
].join("\n");
}

228
sister-agent/src/git-ops.ts Normal file
View File

@@ -0,0 +1,228 @@
import { spawn } from "node:child_process";
import { access } from "node:fs/promises";
const GITEA_BASE_URL =
process.env["GITEA_BASE_URL"] ?? "https://git.nabomhalang.co.kr";
const GITEA_ORG = process.env["GITEA_ORG"] ?? "hanarang";
const GITEA_TOKEN = process.env["GITEA_TOKEN"] ?? "";
const GIT_USER_NAME = process.env["GIT_USER_NAME"] ?? "rails-agent";
const GIT_USER_EMAIL = process.env["GIT_USER_EMAIL"] ?? "rails@hanarang.local";
export interface GitPushResult {
ok: boolean;
repoUrl: string;
rawUrlBase: string;
commit: string;
filesCount: number;
error?: string;
}
async function runCmd(
cmd: string,
args: string[],
cwd: string,
env: Record<string, string> = {},
): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolveFn) => {
const child = spawn(cmd, args, {
cwd,
env: { ...process.env, ...env } as NodeJS.ProcessEnv,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (b: Buffer) => (stdout += b.toString()));
child.stderr.on("data", (b: Buffer) => (stderr += b.toString()));
child.on("exit", (code) => resolveFn({ code: code ?? -1, stdout, stderr }));
child.on("error", () => resolveFn({ code: -1, stdout, stderr }));
});
}
async function ensureGiteaRepo(name: string, description: string): Promise<boolean> {
if (!GITEA_TOKEN) return false;
// Check if org-level repo exists
const checkUrl = `${GITEA_BASE_URL}/api/v1/repos/${GITEA_ORG}/${name}`;
try {
const res = await fetch(checkUrl, {
headers: { Authorization: `token ${GITEA_TOKEN}` },
});
if (res.ok) return true;
if (res.status !== 404) return false;
} catch {
return false;
}
// Create the repo under the org
const createUrl = `${GITEA_BASE_URL}/api/v1/orgs/${GITEA_ORG}/repos`;
try {
const res = await fetch(createUrl, {
method: "POST",
headers: {
Authorization: `token ${GITEA_TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify({
name,
description: description.slice(0, 255),
private: false,
auto_init: false,
default_branch: "main",
}),
});
return res.ok;
} catch {
return false;
}
}
/**
* Initialize git in the workspace dir and push everything to a pipeline-specific
* repo on Gitea. Returns the repo URL and a commit hash on success.
*
* The repo name is derived from the pipeline id: `rails-<short>`.
* If the repo doesn't exist, it's created via Gitea API.
*/
export async function commitAndPush(opts: {
workspaceDir: string;
pipelineId: string;
projectName: string;
stage: string;
agentName: string;
}): Promise<GitPushResult> {
if (!GITEA_TOKEN) {
return {
ok: false,
repoUrl: "",
rawUrlBase: "",
commit: "",
filesCount: 0,
error: "GITEA_TOKEN not configured",
};
}
try {
await access(opts.workspaceDir);
} catch {
return {
ok: false,
repoUrl: "",
rawUrlBase: "",
commit: "",
filesCount: 0,
error: `workspace does not exist: ${opts.workspaceDir}`,
};
}
const repoName = `rails-${opts.pipelineId.slice(-10).toLowerCase()}`;
const description = `Rails pipeline ${opts.pipelineId}${opts.projectName}`;
const ok = await ensureGiteaRepo(repoName, description);
if (!ok) {
return {
ok: false,
repoUrl: "",
rawUrlBase: "",
commit: "",
filesCount: 0,
error: `failed to ensure gitea repo ${repoName}`,
};
}
const repoUrlHttps = `${GITEA_BASE_URL}/${GITEA_ORG}/${repoName}`;
const pushUrl = `${GITEA_BASE_URL.replace(
/^https:\/\//,
`https://${GIT_USER_NAME}:${GITEA_TOKEN}@`,
)}/${GITEA_ORG}/${repoName}.git`;
const env: Record<string, string> = {
GIT_AUTHOR_NAME: GIT_USER_NAME,
GIT_AUTHOR_EMAIL: GIT_USER_EMAIL,
GIT_COMMITTER_NAME: GIT_USER_NAME,
GIT_COMMITTER_EMAIL: GIT_USER_EMAIL,
};
// git init (idempotent)
await runCmd("git", ["init", "-b", "main"], opts.workspaceDir, env);
await runCmd("git", ["config", "user.name", GIT_USER_NAME], opts.workspaceDir, env);
await runCmd("git", ["config", "user.email", GIT_USER_EMAIL], opts.workspaceDir, env);
// Track all files
const addResult = await runCmd("git", ["add", "-A"], opts.workspaceDir, env);
if (addResult.code !== 0) {
return {
ok: false,
repoUrl: repoUrlHttps,
rawUrlBase: "",
commit: "",
filesCount: 0,
error: `git add failed: ${addResult.stderr.slice(0, 300)}`,
};
}
const msg = `${opts.agentName}/${opts.stage}: pipeline ${opts.pipelineId.slice(-10)}`;
const commitResult = await runCmd(
"git",
["commit", "-m", msg, "--allow-empty"],
opts.workspaceDir,
env,
);
if (commitResult.code !== 0 && !commitResult.stdout.includes("nothing to commit")) {
return {
ok: false,
repoUrl: repoUrlHttps,
rawUrlBase: "",
commit: "",
filesCount: 0,
error: `git commit failed: ${commitResult.stderr.slice(0, 300)}`,
};
}
// Set remote + push
await runCmd("git", ["remote", "remove", "origin"], opts.workspaceDir, env);
await runCmd("git", ["remote", "add", "origin", pushUrl], opts.workspaceDir, env);
const pushResult = await runCmd(
"git",
["push", "-u", "origin", "main", "--force"],
opts.workspaceDir,
env,
);
if (pushResult.code !== 0) {
return {
ok: false,
repoUrl: repoUrlHttps,
rawUrlBase: "",
commit: "",
filesCount: 0,
error: `git push failed: ${pushResult.stderr.slice(0, 400)}`,
};
}
// Fetch latest commit hash for reporting
const hashResult = await runCmd(
"git",
["rev-parse", "--short", "HEAD"],
opts.workspaceDir,
env,
);
const commit = hashResult.stdout.trim();
// Count files tracked in the commit
const fileList = await runCmd(
"git",
["ls-files"],
opts.workspaceDir,
env,
);
const filesCount = fileList.stdout.trim().split("\n").filter(Boolean).length;
const rawUrlBase = `${repoUrlHttps}/raw/branch/main`;
return {
ok: true,
repoUrl: repoUrlHttps,
rawUrlBase,
commit,
filesCount,
};
}

View File

@@ -0,0 +1 @@
import "./server.js";

11
sister-agent/src/llm.ts Normal file
View File

@@ -0,0 +1,11 @@
// Shim for backwards compatibility — the adapter implementations now live
// in ./llm/. Import from ./llm/index.js for new code.
export {
callLlm,
createLlmAdapter,
getLlmAdapter,
type LlmAdapter,
type LlmRequest,
type LlmResult,
type ProviderName,
} from "./llm/index.js";

View File

@@ -0,0 +1,18 @@
export interface LlmRequest {
prompt: string;
model: string;
timeoutMs: number;
}
export interface LlmResult {
ok: boolean;
text: string;
provider: string;
model: string;
errorMessage?: string;
}
export interface LlmAdapter {
readonly name: string;
infer(req: LlmRequest): Promise<LlmResult>;
}

View File

@@ -0,0 +1,97 @@
import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js";
/**
* Anthropic Messages API adapter.
*
* Required env:
* ANTHROPIC_API_KEY — your API key
* Optional env:
* ANTHROPIC_BASE_URL — override endpoint (defaults to api.anthropic.com)
* ANTHROPIC_VERSION — API version header (defaults to 2023-06-01)
*/
export class AnthropicAdapter implements LlmAdapter {
readonly name = "anthropic";
private readonly apiKey: string;
private readonly baseUrl: string;
private readonly version: string;
constructor(opts: { apiKey?: string; baseUrl?: string; version?: string } = {}) {
this.apiKey = opts.apiKey ?? process.env["ANTHROPIC_API_KEY"] ?? "";
this.baseUrl =
opts.baseUrl ??
process.env["ANTHROPIC_BASE_URL"] ??
"https://api.anthropic.com";
this.version =
opts.version ?? process.env["ANTHROPIC_VERSION"] ?? "2023-06-01";
}
async infer(req: LlmRequest): Promise<LlmResult> {
if (!this.apiKey) {
return {
ok: false,
text: "",
provider: this.name,
model: req.model,
errorMessage: "ANTHROPIC_API_KEY not configured",
};
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), req.timeoutMs);
try {
const res = await fetch(`${this.baseUrl}/v1/messages`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": this.apiKey,
"anthropic-version": this.version,
},
body: JSON.stringify({
model: req.model,
max_tokens: 4096,
messages: [{ role: "user", content: req.prompt }],
}),
signal: controller.signal,
});
clearTimeout(timer);
if (!res.ok) {
const body = await res.text();
return {
ok: false,
text: "",
provider: this.name,
model: req.model,
errorMessage: `anthropic HTTP ${res.status}: ${body.slice(0, 400)}`,
};
}
const data = (await res.json()) as {
content?: Array<{ type: string; text?: string }>;
model?: string;
};
const text =
data.content
?.filter((c) => c.type === "text")
.map((c) => c.text ?? "")
.join("") ?? "";
return {
ok: true,
text,
provider: this.name,
model: data.model ?? req.model,
};
} catch (err) {
clearTimeout(timer);
return {
ok: false,
text: "",
provider: this.name,
model: req.model,
errorMessage:
err instanceof Error ? err.message : String(err),
};
}
}
}

View File

@@ -0,0 +1,75 @@
import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js";
import { OpenClawAdapter } from "./openclaw.js";
import { OpenAiAdapter } from "./openai.js";
import { AnthropicAdapter } from "./anthropic.js";
import { OllamaAdapter } from "./ollama.js";
import { MockAdapter } from "./mock.js";
export type { LlmAdapter, LlmRequest, LlmResult };
export type ProviderName =
| "openclaw"
| "openai"
| "anthropic"
| "ollama"
| "mock";
/**
* Build an adapter from env / explicit override.
*
* Provider selection order:
* 1. explicit `opts.provider`
* 2. env LLM_PROVIDER
* 3. default: "mock" (safe fallback — won't accidentally spend money)
*/
export function createLlmAdapter(
opts: { provider?: ProviderName } = {},
): LlmAdapter {
const raw =
opts.provider ??
(process.env["LLM_PROVIDER"] as ProviderName | undefined) ??
"mock";
switch (raw) {
case "openclaw":
return new OpenClawAdapter();
case "openai":
return new OpenAiAdapter();
case "anthropic":
return new AnthropicAdapter();
case "ollama":
return new OllamaAdapter();
case "mock":
return new MockAdapter();
default: {
const exhaustive: never = raw;
throw new Error(
`Unknown LLM_PROVIDER: ${exhaustive as string}. ` +
`Supported: openclaw, openai, anthropic, ollama, mock`,
);
}
}
}
// Module-level singleton so we don't rebuild the adapter on every LLM call.
let cached: LlmAdapter | null = null;
export function getLlmAdapter(): LlmAdapter {
if (!cached) cached = createLlmAdapter();
return cached;
}
/** Convenience wrapper — kept signature-compatible with the old callLlm(). */
export async function callLlm(opts: {
prompt: string;
model?: string;
modelOverride?: string;
timeoutMs?: number;
}): Promise<LlmResult> {
const adapter = getLlmAdapter();
return adapter.infer({
prompt: opts.prompt,
model: opts.modelOverride ?? opts.model ?? "",
timeoutMs: opts.timeoutMs ?? 120_000,
});
}

View File

@@ -0,0 +1,42 @@
import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js";
/**
* Mock adapter — returns a deterministic fake response based on the role
* hint in the prompt. Useful for smoke tests and offline demos where no
* real LLM credentials are available.
*/
export class MockAdapter implements LlmAdapter {
readonly name = "mock";
async infer(req: LlmRequest): Promise<LlmResult> {
// tiny delay so upstream concurrency code behaves as if it's async
await new Promise((r) => setTimeout(r, 40));
const isImpl = /implement|구현/.test(req.prompt);
const isJunior = /junior|신입/.test(req.prompt);
let text: string;
if (isImpl && isJunior) {
text = [
"간단한 샘플 산출물입니다.",
"",
"```html:frontend/index.html",
"<!doctype html>",
"<html>",
"<head><meta charset=\"utf-8\"><title>mock</title></head>",
"<body><h1>Hello from mock adapter</h1></body>",
"</html>",
"```",
].join("\n");
} else {
text = `# Mock ${req.model}\n\n이 응답은 MockAdapter 가 생성한 결정론적 더미입니다. 실제 LLM 응답이 아닙니다.`;
}
return {
ok: true,
text,
provider: this.name,
model: req.model,
};
}
}

View File

@@ -0,0 +1,72 @@
import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js";
/**
* Ollama adapter — for local LLM inference via https://ollama.com
*
* Optional env:
* OLLAMA_BASE_URL — defaults to http://localhost:11434
*
* Example model names: llama3.1, qwen2.5-coder, mistral, deepseek-coder
*/
export class OllamaAdapter implements LlmAdapter {
readonly name = "ollama";
private readonly baseUrl: string;
constructor(opts: { baseUrl?: string } = {}) {
this.baseUrl =
opts.baseUrl ??
process.env["OLLAMA_BASE_URL"] ??
"http://localhost:11434";
}
async infer(req: LlmRequest): Promise<LlmResult> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), req.timeoutMs);
try {
const res = await fetch(`${this.baseUrl}/api/generate`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: req.model,
prompt: req.prompt,
stream: false,
options: { temperature: 0.3 },
}),
signal: controller.signal,
});
clearTimeout(timer);
if (!res.ok) {
const body = await res.text();
return {
ok: false,
text: "",
provider: this.name,
model: req.model,
errorMessage: `ollama HTTP ${res.status}: ${body.slice(0, 400)}`,
};
}
const data = (await res.json()) as {
response?: string;
model?: string;
};
return {
ok: true,
text: data.response ?? "",
provider: this.name,
model: data.model ?? req.model,
};
} catch (err) {
clearTimeout(timer);
return {
ok: false,
text: "",
provider: this.name,
model: req.model,
errorMessage:
err instanceof Error ? err.message : String(err),
};
}
}
}

View File

@@ -0,0 +1,91 @@
import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js";
/**
* OpenAI Chat Completions adapter.
*
* Required env:
* OPENAI_API_KEY — your API key
* Optional env:
* OPENAI_BASE_URL — override endpoint (defaults to api.openai.com/v1)
* Use this for Azure OpenAI, OpenRouter, local
* llama.cpp servers that speak the OpenAI protocol,
* etc.
*/
export class OpenAiAdapter implements LlmAdapter {
readonly name = "openai";
private readonly apiKey: string;
private readonly baseUrl: string;
constructor(opts: { apiKey?: string; baseUrl?: string } = {}) {
this.apiKey = opts.apiKey ?? process.env["OPENAI_API_KEY"] ?? "";
this.baseUrl =
opts.baseUrl ??
process.env["OPENAI_BASE_URL"] ??
"https://api.openai.com/v1";
}
async infer(req: LlmRequest): Promise<LlmResult> {
if (!this.apiKey) {
return {
ok: false,
text: "",
provider: this.name,
model: req.model,
errorMessage: "OPENAI_API_KEY not configured",
};
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), req.timeoutMs);
try {
const res = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: req.model,
messages: [{ role: "user", content: req.prompt }],
temperature: 0.3,
}),
signal: controller.signal,
});
clearTimeout(timer);
if (!res.ok) {
const body = await res.text();
return {
ok: false,
text: "",
provider: this.name,
model: req.model,
errorMessage: `openai HTTP ${res.status}: ${body.slice(0, 400)}`,
};
}
const data = (await res.json()) as {
choices?: Array<{ message?: { content?: string } }>;
model?: string;
};
const text = data.choices?.[0]?.message?.content ?? "";
return {
ok: true,
text,
provider: this.name,
model: data.model ?? req.model,
};
} catch (err) {
clearTimeout(timer);
return {
ok: false,
text: "",
provider: this.name,
model: req.model,
errorMessage:
err instanceof Error ? err.message : String(err),
};
}
}
}

View File

@@ -0,0 +1,108 @@
import { spawn } from "node:child_process";
import type { LlmAdapter, LlmRequest, LlmResult } from "./adapter.js";
/**
* OpenClaw adapter — invokes the nabomhalang internal OpenClaw runtime via its
* `openclaw infer model run --json` subprocess. This is the original adapter
* used by the hanarang 4-sister deployment.
*
* External users will most likely NOT have OpenClaw installed. They should
* use the `openai`, `anthropic`, `ollama`, or `mock` adapters instead.
*/
export class OpenClawAdapter implements LlmAdapter {
readonly name = "openclaw";
private readonly bin: string;
constructor(opts: { bin?: string } = {}) {
this.bin =
opts.bin ??
process.env["OPENCLAW_BIN"] ??
`${process.env["HOME"]}/.npm-global/bin/openclaw`;
}
async infer(req: LlmRequest): Promise<LlmResult> {
// Note: we intentionally do NOT pass `--model` to openclaw. OpenClaw has
// its own per-agent model allowlist and routing logic, and overriding it
// with rails-side role names like `gpt-5.4` / `glm-5-turbo` causes
// "Model override not allowed for agent main" errors. Other adapters
// (openai/anthropic/ollama) still honor req.model — only this adapter
// delegates model selection back to the runtime.
const args = ["infer", "model", "run", "--prompt", req.prompt, "--json"];
return new Promise((resolveFn) => {
const child = spawn(this.bin, args, {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill("SIGKILL");
resolveFn({
ok: false,
text: "",
provider: this.name,
model: req.model || "default",
errorMessage: `openclaw timeout after ${req.timeoutMs}ms`,
});
}, req.timeoutMs);
child.stdout.on("data", (b: Buffer) => (stdout += b.toString()));
child.stderr.on("data", (b: Buffer) => (stderr += b.toString()));
child.on("error", (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolveFn({
ok: false,
text: "",
provider: this.name,
model: req.model || "default",
errorMessage: `openclaw spawn error: ${err.message}`,
});
});
child.on("exit", (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code !== 0) {
resolveFn({
ok: false,
text: "",
provider: this.name,
model: req.model || "default",
errorMessage: `openclaw exit ${code}: ${stderr.slice(0, 500)}`,
});
return;
}
try {
const parsed = JSON.parse(stdout) as {
ok: boolean;
provider: string;
model: string;
outputs: Array<{ text: string }>;
};
resolveFn({
ok: parsed.ok,
text: parsed.outputs?.[0]?.text ?? "",
provider: parsed.provider || this.name,
model: parsed.model || req.model,
});
} catch (err) {
resolveFn({
ok: false,
text: "",
provider: this.name,
model: req.model || "default",
errorMessage: `openclaw JSON parse failed: ${
err instanceof Error ? err.message : String(err)
}`,
});
}
});
});
}
}

203
sister-agent/src/planner.ts Normal file
View File

@@ -0,0 +1,203 @@
import type { ComplexityScore, ComplexityTier } from "./complexity.js";
import type { Role } from "./types.js";
export interface SpawnPlan {
role: Role;
count: number;
subBreakdown?: SpawnPlan[]; // nested hierarchy
rationale: string;
}
export interface DecompositionPlan {
tier: ComplexityTier;
score: number;
strategy:
| "direct" // manager executes directly, no spawn
| "single-junior" // 1 junior only
| "lead-team" // 1 lead + juniors
| "principal-team" // 1 principal + leads + juniors
| "fanout"; // massive — 2 principals in parallel
spawn: SpawnPlan[];
notes: string[];
}
/**
* Plan the team structure for a given complexity score.
* Deterministic — no LLM required.
*
* Manager can override this plan if LLM refinement is enabled.
*/
export function planDecomposition(complexity: ComplexityScore): DecompositionPlan {
const { score, tier } = complexity;
switch (tier) {
case "trivial":
// Even trivial tasks need a junior to actually produce code. Managers
// are planner-only by role definition and maybeExtractFiles() in
// spawn.ts only saves files from juniors. Without a junior the
// pipeline completes "successfully" with zero output — the classic
// ghost-pipeline bug. Spawn 1 junior to guarantee something lands.
return {
tier,
score,
strategy: "single-junior",
spawn: [
{
role: "junior",
count: 1,
rationale:
"Trivial task still needs one junior to produce actual output. Manager can't write code per role definition.",
},
],
notes: [
"Even trivial tasks spawn one junior so the pipeline actually produces files.",
],
};
case "simple":
return {
tier,
score,
strategy: "single-junior",
spawn: [
{
role: "junior",
count: 1,
rationale: "Single junior handles the task directly.",
},
],
notes: [],
};
case "moderate":
return {
tier,
score,
strategy: "lead-team",
spawn: [
{
role: "lead",
count: 1,
rationale: "Lead coordinates 2 juniors for moderate scope.",
subBreakdown: [
{
role: "junior",
count: 2,
rationale: "Two juniors execute parallel sub-tasks.",
},
],
},
],
notes: [
"Lead decides the exact sub-task split at runtime.",
],
};
case "complex":
return {
tier,
score,
strategy: "principal-team",
spawn: [
{
role: "principal",
count: 1,
rationale: "Principal handles architecture review + decomposition.",
subBreakdown: [
{
role: "lead",
count: 2,
rationale: "Two leads run parallel workstreams.",
subBreakdown: [
{
role: "junior",
count: 2,
rationale: "Two juniors per lead.",
},
],
},
],
},
],
notes: [
"Principal may bypass lead and spawn juniors directly when bottleneck detected.",
],
};
case "massive":
return {
tier,
score,
strategy: "fanout",
spawn: [
{
role: "principal",
count: 2,
rationale: "Two principals split the work by domain (e.g., FE / BE).",
subBreakdown: [
{
role: "lead",
count: 2,
rationale: "Each principal runs 2 parallel leads.",
subBreakdown: [
{
role: "junior",
count: 3,
rationale: "Three juniors per lead for massive throughput.",
},
],
},
],
},
],
notes: [
"Total worst-case: 2 principals + 4 leads + 12 juniors.",
"Manager monitors and rebalances on escalation.",
],
};
}
}
/**
* Count total nodes in a decomposition plan (for concurrency budgeting).
*/
export function countPlanNodes(plan: DecompositionPlan): number {
const count = (spawns: SpawnPlan[]): number => {
let total = 0;
for (const s of spawns) {
total += s.count;
if (s.subBreakdown) total += s.count * count(s.subBreakdown);
}
return total;
};
// +1 for the manager itself
return 1 + count(plan.spawn);
}
/**
* Check if a plan fits within concurrency budget.
* Returns a trimmed plan if over budget.
*/
export function enforceConcurrencyBudget(
plan: DecompositionPlan,
budget: number,
): DecompositionPlan {
const nodeCount = countPlanNodes(plan);
if (nodeCount <= budget) return plan;
// Over budget — trim sub-breakdowns
const trimmed = JSON.parse(JSON.stringify(plan)) as DecompositionPlan;
const trimFactor = budget / nodeCount;
const trim = (spawns: SpawnPlan[]): void => {
for (const s of spawns) {
s.count = Math.max(1, Math.floor(s.count * trimFactor));
if (s.subBreakdown) trim(s.subBreakdown);
}
};
trim(trimmed.spawn);
trimmed.notes.push(
`Trimmed from ${nodeCount}${countPlanNodes(trimmed)} nodes to fit budget ${budget}.`,
);
return trimmed;
}

324
sister-agent/src/prompts.ts Normal file
View File

@@ -0,0 +1,324 @@
import type { Role } from "./types.js";
export interface PromptContext {
role: Role;
agentName: string; // harang/narang/darang/erang
stage: "plan" | "implement" | "review" | "deploy";
taskTitle: string;
taskDescription: string;
prevStageOutput?: string;
parentTitle?: string;
priorStages?: Array<{ stage: string; text: string }>;
}
const STAGE_KOREAN: Record<string, string> = {
plan: "기획",
implement: "구현",
review: "검토",
deploy: "배포",
};
const ROLE_KOREAN: Record<Role, string> = {
manager: "부장",
principal: "수석",
lead: "선임",
junior: "신입",
};
const ROLE_RESPONSIBILITY: Record<Role, string> = {
manager:
"팀 전체의 전략을 결정하고 최종 결과물의 품질을 책임진다. 본인이 직접 코드를 짜지 않고 아래 팀에 분배한다.",
principal:
"기술적 분해와 리뷰를 담당한다. 부장의 방향을 받아 구체적인 실행 단위로 쪼갠다.",
lead:
"실행 리드. 작은 팀을 조율하면서 신입의 작업물을 검증하고 합친다.",
junior:
"한 가지 명확한 작업을 직접 실행한다. 결과물(텍스트, 코드, 답변)을 명확하게 제출한다.",
};
/**
* Build the prompt the LLM will see for this node.
* The pattern: short system context + concrete task + previous output (if any).
*
* Output format hint: ask for plain text. Keeping it simple — no JSON parsing
* required from the LLM (we already have structure from the spawn tree).
*/
export function buildPrompt(ctx: PromptContext): string {
const stageKor = STAGE_KOREAN[ctx.stage] ?? ctx.stage;
const roleKor = ROLE_KOREAN[ctx.role];
const lines: string[] = [];
lines.push(`# 역할`);
lines.push(
`너는 "${ctx.agentName}" 자매의 ${roleKor}(${ctx.role})이다. ${ROLE_RESPONSIBILITY[ctx.role]}`,
);
lines.push("");
lines.push(`# 현재 단계`);
lines.push(`${stageKor} (stage=${ctx.stage})`);
lines.push("");
lines.push(`# 작업`);
lines.push(`제목: ${ctx.taskTitle}`);
if (ctx.taskDescription) {
lines.push(`상세: ${ctx.taskDescription}`);
}
if (ctx.parentTitle) {
lines.push(`상위 작업: ${ctx.parentTitle}`);
}
if (ctx.priorStages && ctx.priorStages.length > 0) {
lines.push("");
lines.push(`# 앞 단계(들)의 결과물 — 반드시 처음부터 끝까지 모두 읽고 일관되게 이어가`);
lines.push(
`(아래 각 단계 본문은 잘리지 않은 원본이다. 코드가 중간에 끝난 것처럼 보이면 그것은 잘림이 아니라 진짜 끝이다.)`,
);
for (const ps of ctx.priorStages) {
lines.push("");
lines.push(`## ${STAGE_KOREAN[ps.stage] ?? ps.stage} 단계 결과`);
// Cap matches the upstream spawn.ts aggregation (64KB). LLM context
// windows are 200k+ tokens so this fits comfortably even after
// multiple stages accumulate.
lines.push(ps.text.slice(0, 64_000));
}
}
if (ctx.prevStageOutput) {
lines.push("");
lines.push(`# 직전 상위 노드(같은 stage) 의 지시`);
lines.push(ctx.prevStageOutput.slice(0, 32_000));
}
lines.push("");
lines.push(`# 출력 형식`);
lines.push(roleOutputHint(ctx.role, ctx.stage));
lines.push(`반드시 한국어로 답해. 핵심만 간결하게.`);
// 모든 작업 결과 끝에 디스코드용 한 줄 멘트를 LLM 이 직접 emit 하게 한다.
// sister-agent 가 이 블록을 추출해 stage-end Discord notify 메시지로 사용
// 한다 (없으면 hardcoded 풀로 fallback). junior 가 가장 작업 내용을 잘
// 알기 때문에 junior 에만 요청한다 — manager 는 작업 시작 전에 결정만 함.
if (ctx.role === "junior") {
lines.push("");
lines.push(discordLineFooter(ctx));
}
return lines.join("\n");
}
/**
* Footer instructing the junior LLM to append a `discord-line` block at
* the end of its response. The block is parsed by the sister-agent and
* used as the stage-end Discord notification message.
*
* Persona context (자매 정체성) is included so the LLM matches tone:
* harang — 차분/단정
* narang — 활달/실용
* darang — 꼼꼼/엄격
* erang — 차분/믿음직
*/
function discordLineFooter(ctx: PromptContext): string {
const persona: Record<string, string> = {
harang: "차분하고 단정한 plan 단계 부장",
narang: "활달하고 실용적인 implement 단계 부장",
darang: "꼼꼼하고 엄격한 review 단계 부장",
erang: "차분하고 믿음직한 deploy 단계 부장",
};
const exampleByStage: Record<string, string> = {
plan: '"📋 MVP 범위 잡았어. 나랑이 받아."',
implement: '"🔨 todo HTML 5개 함수 박았어. 다랑아 봐줘."',
review:
'"🔍 체크리스트 다 ✓. 이랑이 받아." (APPROVE) 또는 ' +
'"⚠️ addTodo 가 빈 입력 처리 못 함. 나랑아 다시 봐줘." (REQUEST_CHANGES)',
deploy: '"🚀 todo-mvp.html 검증 완료. 안전해."',
};
return [
`# 디스코드 알림 한 줄`,
`자기야가 디스코드 채널에서 보게 될 너의 한 줄 보고를 마지막에 추가해.`,
`너는 ${persona[ctx.agentName] ?? ctx.agentName} 의 페르소나를 살려.`,
`방금 너가 한 작업의 핵심을 한 줄로 요약 (50 자 이내, 이모지 1-2개).`,
`결과가 실패/REQUEST_CHANGES/ABORT 면 그 사실을 명확히 (✗/⚠️/🛑 중 하나) 표시.`,
``,
`**정확히 다음 형식으로** 응답 맨 끝에 추가:`,
"```discord-line",
"<여기에 한 줄>",
"```",
``,
`예시 (${STAGE_KOREAN[ctx.stage]}):`,
exampleByStage[ctx.stage] ?? '"✅ 작업 완료"',
``,
`이 블록은 따로 파싱되니까 위 형식 정확히 지켜. 본문 어디 다른 곳에는 같은 형식 쓰지 마.`,
].join("\n");
}
function roleOutputHint(role: Role, stage: PromptContext["stage"]): string {
// Stage-specific instructions take precedence. The original "decompose
// into team" wording only makes sense for plan / implement — for review
// and deploy it's actively wrong, because the manager would then output
// a fake team plan ("수석 1명은…, 선임 1명은…") instead of an actual
// verdict.
if (stage === "review") {
return reviewHintForRole(role);
}
if (stage === "deploy") {
return deployHintForRole(role);
}
// ── plan / implement ─────────────────────────────────────────
// IMPORTANT: 팀 분배 (수석/선임/신입 N명...) narration 은 금지다.
// 하위 노드의 spawn 트리는 sister-agent 의 planner.ts 가 complexity score 로
// 결정론적으로 결정한다. LLM manager 는 spawn 결정에 영향을 주지 않으며,
// "수석 1명을 붙일게" 같은 prose 는 빈 약속 + 토큰 낭비 + 사용자 혼란이다.
// 대신 manager 는 이 stage 의 진짜 결정 (범위/스택/파일 경계) 만 짧게.
if (role === "manager") {
if (stage === "plan") {
return [
`너는 이 단계의 최종 결정권자다. 팀(수석/선임/신입)이 자동으로 붙으니 분배 narration 은 절대 하지 마.`,
`대신 다음 3 가지만 짧게 결정해서 답해:`,
`1) MVP 범위: 무엇을 포함하나 한 줄`,
`2) 명시적 비범위: 의도적으로 제외할 것 한 줄`,
`3) 통과 기준: 무엇이 동작해야 끝났다고 보는지 한 줄`,
`각 줄은 명령형/단정형으로. "수석/선임/신입" 단어 사용 금지.`,
].join("\n");
}
if (stage === "implement") {
return [
`너는 이 단계의 최종 결정권자다. 팀(수석/선임/신입)이 자동으로 붙으니 분배 narration 은 절대 하지 마.`,
`대신 다음 3 가지만 짧게 결정해서 답해:`,
`1) 기술 스택 / 런타임 한 줄 (예: "vanilla HTML+JS, localStorage")`,
`2) 파일 구조 1~2 줄 (어떤 파일이 만들어지는지)`,
`3) 핵심 구현 결정 한 줄 (상태 관리 방식, 데이터 형태 등)`,
`각 줄은 명령형/단정형으로. "수석/선임/신입" 단어 사용 금지.`,
].join("\n");
}
// Other stages handled above by reviewHintForRole / deployHintForRole
return `이 단계의 핵심 결정 한 문단으로.`;
}
if (role === "principal") {
return `${STAGE_KOREAN[stage]} 단계의 기술적 리스크와 핵심 결정 사항을 bullet 으로 1~3 개. "수석/선임/신입" 같은 팀 narration 금지 — 시스템이 자동으로 분배한다.`;
}
if (role === "lead") {
return `${STAGE_KOREAN[stage]} 단계에서 검증해야 할 핵심 포인트를 bullet 1~3 개. 팀 분배 narration 금지.`;
}
// junior
if (stage === "plan") {
return `이 프로젝트의 핵심 plan 을 마크다운 bullet 형식으로 작성해. "수석/선임/신입" 단어 사용 금지.`;
}
if (stage === "implement") {
return [
`요구된 코드/파일을 실제로 작성해.`,
`각 파일을 코드 블록으로 감싸고, **반드시 다음 형식으로 파일 경로를 명시**해:`,
"```html:src/index.html",
"<!DOCTYPE html>...",
"```",
`경로는 프로젝트 루트 기준 상대 경로. 언어 태그 콜론 뒤에 경로.`,
`여러 파일이 필요하면 각각 별도 블록으로. 설명은 최소화.`,
`"수석/선임/신입" 같은 팀 narration 은 코드 출력 안에 포함하지 마.`,
].join("\n");
}
return `결과를 명확히 제출해.`;
}
/**
* Review-stage hints: every role outputs an actual verdict, never a team
* plan. The manager is the FINAL authority and must commit to APPROVE or
* REQUEST_CHANGES — no decomposition, no delegation, no "수석 1명은…" lists.
*
* Manager output format is locked into a DoD checklist. The reviewer must
* extract concrete acceptance criteria from priorStages.plan ("통과 기준",
* "MVP 범위") and check each one against the implement result. This forces
* the LLM to think in terms of testable items instead of generic prose.
*/
function reviewHintForRole(role: Role): string {
switch (role) {
case "manager":
return [
`너는 review 단계의 최종 결정권자다. 절대 작업을 분해하거나 팀(수석/선임/신입)을 배치하지 마. 본인이 직접 결정한다.`,
``,
`## 입력`,
`위 priorStages 에는 다음이 들어 있다:`,
`- plan 단계 결과: 하랑이가 정한 MVP 범위 / 비범위 / 통과 기준`,
`- implement 단계 결과: 나랑이가 만든 실제 코드 본문 (잘리지 않은 원본)`,
``,
`## 작업 절차 (정확히 이 순서)`,
`1. plan 단계의 "MVP 범위" 와 "통과 기준" 에서 **검증 가능한 항목** 을 3~6개 추출한다. 추상적인 항목 말고 구체적으로 코드에서 확인 가능한 것 (예: "추가 버튼이 있고 동작함", "삭제 후 새로고침 시 유지됨").`,
`2. 각 항목을 implement 코드에서 직접 찾아 통과/미달 판정한다.`,
`3. 모든 항목이 통과면 APPROVE, 하나라도 미달이면 REQUEST_CHANGES, 본질적으로 잘못된 접근이면 ABORT.`,
``,
`## 출력 형식 (정확히 이대로)`,
``,
`\`\`\``,
`## DoD 체크리스트`,
`- [✓|✗] <항목 1 한 줄 설명> — <근거: 어떤 파일의 어떤 부분에서 확인됨>`,
`- [✓|✗] <항목 2 한 줄 설명> — <근거>`,
`- [✓|✗] <항목 3 한 줄 설명> — <근거>`,
`(필요하면 더)`,
``,
`## 최종 결정`,
`APPROVE | REQUEST_CHANGES | ABORT`,
``,
`## 결정 근거`,
`<한 문단 — 어떤 항목이 결정적으로 통과/미달인지 한국어로>`,
`\`\`\``,
``,
`## 엄격한 금지`,
`- 작업 분배, 가상 팀 구성, "수석/선임/신입" 단어 사용`,
`- "내가 마지막에 본다" 같은 미래 약속`,
`- 코드를 다시 작성하거나 새 코드 제안 (그건 implement 단계의 일)`,
`- DoD 체크리스트 없이 prose 만 출력하는 것 (반드시 위 형식)`,
`- "✓" 가 아닌 "통과", "OK" 같은 단어 사용 (파서가 못 잡음)`,
``,
`## 보너스 규칙`,
`- 사용자가 요구사항에 의도적으로 모순/제한 (예: "함수를 비워줘") 을 넣었으면 그건 새 DoD 다. 그 의도를 충족하면 APPROVE.`,
`- minor 한 스타일 / 주석 누락은 REQUEST_CHANGES 가 아니다. critical/major 만 카운트.`,
].join("\n");
case "principal":
return [
`너는 기술 리뷰 담당이다. plan 의 통과 기준과 implement 코드를 보고 critical/major 결함만 1~3개 골라 bullet 로 정리해.`,
``,
`형식 (정확히 이대로):`,
`- [critical|major] <어느 파일/라인/함수> — <무엇이 문제> — <왜 문제> — <어떻게 고쳐야>`,
``,
`minor / recommendation 은 적지 마. 작업을 분배하거나 팀을 구성하지 마.`,
].join("\n");
case "lead":
return [
`너는 기능 동작 검증 담당이다. plan 의 "통과 기준" 에서 핵심 기능을 추출하고, 각 기능별로 implement 코드에서 동작 여부를 한 줄씩 적어.`,
``,
`형식 (정확히 이대로):`,
`✓ <기능명>: 동작 OK — <근거: 어느 함수가 어떻게 처리>`,
`✗ <기능명>: 실패 — <원인: 어떤 코드가 빠지거나 잘못됨>`,
``,
`작업을 분배하거나 신입에게 위임하지 마. 새 코드 제안 금지.`,
].join("\n");
case "junior":
return [
`위 priorStages 의 implement 결과물 코드를 직접 읽고 plan 의 통과 기준과 비교해 평가해.`,
`첫 줄에 \`APPROVE\` 또는 \`REQUEST_CHANGES\` 또는 \`ABORT\` 로만 시작.`,
`그 다음 줄부터 한 문단 이내로 핵심 이유. 코드를 다시 작성하지 마.`,
].join("\n");
}
}
/**
* Deploy-stage hints: every role focuses on deployability / verification,
* never on decomposition.
*/
function deployHintForRole(role: Role): string {
switch (role) {
case "manager":
return [
`너는 deploy 단계의 최종 결정권자다. 작업을 분해하거나 팀을 배치하지 마.`,
`위 priorStages 의 review 결과 + implement 결과물을 보고 배포 검증 결과를 한 문단으로 종합한 뒤,`,
`**마지막 줄** 에 \`DEPLOY_DONE\` 또는 \`DEPLOY_FAILED\` 중 하나만 적어.`,
].join("\n");
case "principal":
return [
`배포 환경에서 발생할 수 있는 리스크 (브라우저 호환성, CSP, CDN, 의존성 누락 등) 를 1~3개 bullet 로.`,
`해당 없으면 "리스크 없음" 한 줄.`,
].join("\n");
case "lead":
return [
`배포 후 즉시 확인할 검증 체크리스트를 bullet 로. 각 항목은 "□ <확인 절차>" 형식.`,
].join("\n");
case "junior":
return [
`이 결과물을 어떻게 배포 검증할지 짧게 설명하고 마지막 줄에 "DEPLOY_DONE" 또는 "DEPLOY_FAILED" 표기.`,
].join("\n");
}
}

View File

@@ -0,0 +1,60 @@
import type { SubTaskRecord } from "./types.js";
export class RailsClient {
constructor(private readonly baseUrl: string) {}
async createSubTask(record: SubTaskRecord): Promise<void> {
await this.request("POST", "/api/sub-tasks", record);
}
async recordEvent(
subTaskId: string,
eventType: string,
payload: Record<string, unknown>,
): Promise<void> {
await this.request(
"POST",
`/api/sub-tasks/${subTaskId}/events`,
{ eventType, payload },
);
}
async patchSubTask(
id: string,
patch: Record<string, unknown>,
): Promise<void> {
await this.request("PATCH", `/api/sub-tasks/${id}`, patch);
}
private async request(
method: string,
path: string,
body?: unknown,
): Promise<unknown> {
const url = `${this.baseUrl}${path}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
const res = await fetch(url, {
method,
headers: { "content-type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
clearTimeout(timeout);
if (!res.ok) {
const text = await res.text();
throw new Error(`rails API ${method} ${path}${res.status}: ${text}`);
}
const contentType = res.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return await res.json();
}
return await res.text();
} catch (err) {
clearTimeout(timeout);
throw err;
}
}
}

85
sister-agent/src/roles.ts Normal file
View File

@@ -0,0 +1,85 @@
import type { Role } from "./types.js";
export interface RoleConfig {
primaryModel: string;
fallbackModel: string;
canSpawn: Role[];
maxSpawnPerCall: number;
}
/**
* Per-role model defaults. For external users this is almost certainly the
* first thing you'll want to change — the `gpt-5.4` / `glm-*` / `gpt-codex-*`
* names are OpenClaw-internal labels that won't resolve against OpenAI,
* Anthropic, or Ollama directly.
*
* Override priority (highest first):
* 1. env vars:
* LLM_MODEL_MANAGER, LLM_MODEL_PRINCIPAL, LLM_MODEL_LEAD, LLM_MODEL_JUNIOR
* LLM_MODEL_FALLBACK (used for every role's fallback unless you set
* LLM_MODEL_FALLBACK_<ROLE>)
* 2. these hardcoded defaults (OpenClaw-flavored)
*
* Good starting points for a real deployment:
* OpenAI: gpt-4o / gpt-4o-mini
* Anthropic: claude-opus-4-6 / claude-haiku-4-5
* Ollama: qwen2.5-coder:32b / qwen2.5-coder:7b
*/
const OPENCLAW_DEFAULTS: Record<Role, { primary: string; fallback: string }> = {
manager: { primary: "gpt-5.4", fallback: "glm-5.1" },
principal: { primary: "gpt-5.4", fallback: "glm-5.1" },
lead: { primary: "gpt-codex-5.3", fallback: "glm-5" },
junior: { primary: "glm-5-turbo", fallback: "gpt-5" },
};
function envModel(role: Role, kind: "primary" | "fallback"): string | undefined {
const up = role.toUpperCase();
if (kind === "primary") {
return process.env[`LLM_MODEL_${up}`];
}
return (
process.env[`LLM_MODEL_FALLBACK_${up}`] ??
process.env["LLM_MODEL_FALLBACK"]
);
}
function modelFor(role: Role, kind: "primary" | "fallback"): string {
const override = envModel(role, kind);
if (override && override.length > 0) return override;
return OPENCLAW_DEFAULTS[role][kind];
}
export const ROLES: Record<Role, RoleConfig> = {
manager: {
primaryModel: modelFor("manager", "primary"),
fallbackModel: modelFor("manager", "fallback"),
canSpawn: ["principal", "lead", "junior"],
maxSpawnPerCall: 4,
},
principal: {
primaryModel: modelFor("principal", "primary"),
fallbackModel: modelFor("principal", "fallback"),
canSpawn: ["lead", "junior"],
maxSpawnPerCall: 3,
},
lead: {
primaryModel: modelFor("lead", "primary"),
fallbackModel: modelFor("lead", "fallback"),
canSpawn: ["junior"],
maxSpawnPerCall: 4,
},
junior: {
primaryModel: modelFor("junior", "primary"),
fallbackModel: modelFor("junior", "fallback"),
canSpawn: [],
maxSpawnPerCall: 0,
},
};
export const ROLE_KOREAN: Record<Role, string> = {
manager: "부장",
principal: "수석",
lead: "선임",
junior: "신입",
};

151
sister-agent/src/server.ts Normal file
View File

@@ -0,0 +1,151 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { z } from "zod";
import { InvokeRequest } from "./types.js";
import { executeInvocation } from "./spawn.js";
import { RailsClient } from "./rails-client.js";
import { notifyDiscord } from "./discord-notify.js";
const NotifyRequest = z.object({
channelId: z.string().min(1),
message: z.string().min(1),
});
const PORT = parseInt(process.env["SISTER_AGENT_PORT"] ?? "18801", 10);
const AGENT_NAME = process.env["SISTER_AGENT_NAME"] ?? "unknown";
const log = (level: string, msg: string, meta?: Record<string, unknown>): void => {
const line = JSON.stringify({
ts: new Date().toISOString(),
level,
agent: AGENT_NAME,
msg,
...meta,
});
if (level === "error") console.error(line);
else console.log(line);
};
function readJson(req: IncomingMessage): Promise<unknown> {
return new Promise((resolveFn, rejectFn) => {
let body = "";
req.on("data", (chunk: Buffer) => (body += chunk.toString()));
req.on("end", () => {
if (!body) return resolveFn({});
try {
resolveFn(JSON.parse(body));
} catch (err) {
rejectFn(err);
}
});
req.on("error", rejectFn);
});
}
function sendJson(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, {
"content-type": "application/json",
"cache-control": "no-store",
});
res.end(JSON.stringify(body));
}
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
const path = url.pathname;
const method = req.method ?? "GET";
log("info", "request", { method, path });
try {
if (method === "GET" && path === "/health") {
return sendJson(res, 200, {
ok: true,
service: "sister-agent",
agent: AGENT_NAME,
});
}
// ── /notify — fire a Discord message via the local OpenClaw CLI ──
// Used by the rails orchestrator (or any other internal caller) to
// post messages from this sister's bot identity. Best-effort.
if (method === "POST" && path === "/notify") {
const body = await readJson(req);
const parsed = NotifyRequest.safeParse(body);
if (!parsed.success) {
return sendJson(res, 400, {
error: "invalid_notify",
issues: parsed.error.issues,
});
}
const result = await notifyDiscord({
channelId: parsed.data.channelId,
message: parsed.data.message,
});
log(result.ok ? "info" : "warn", "notify", {
channel: parsed.data.channelId,
ok: result.ok,
error: result.error,
});
return sendJson(res, result.ok ? 200 : 502, result);
}
if (method === "POST" && path === "/invoke") {
const body = await readJson(req);
const parsed = InvokeRequest.safeParse(body);
if (!parsed.success) {
return sendJson(res, 400, {
error: "invalid_invoke",
issues: parsed.error.issues,
});
}
// Force agentName to this sister's identity (env), not whatever rails sent.
// The stage info is preserved separately in parsed.data.stage.
const req2 = { ...parsed.data, agentName: AGENT_NAME };
const railsClient = new RailsClient(req2.railsApiUrl);
log("info", "invoke.start", {
pipelineId: req2.pipelineId,
stage: req2.stage,
notifyChannelId: req2.notifyChannelId || "(none)",
});
try {
const result = await executeInvocation(req2, railsClient);
log("info", "invoke.done", {
pipelineId: req2.pipelineId,
stage: req2.stage,
verdict: "verdict" in result ? result.verdict : "?",
});
return sendJson(res, 200, result);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log("error", "invoke.error", {
pipelineId: req2.pipelineId,
stage: req2.stage,
error: msg,
});
return sendJson(res, 500, { error: "invocation_failed", message: msg });
}
}
return sendJson(res, 404, { error: "not_found" });
} catch (err) {
log("error", "request.error", {
error: err instanceof Error ? err.message : String(err),
});
return sendJson(res, 500, { error: "internal_error" });
}
});
server.listen(PORT, "0.0.0.0", () => {
log("info", "sister-agent listening", { port: PORT, agent: AGENT_NAME });
});
const shutdown = (signal: string): void => {
log("info", "shutdown", { signal });
server.close(() => process.exit(0));
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));

807
sister-agent/src/spawn.ts Normal file
View File

@@ -0,0 +1,807 @@
import { ulid } from "ulid";
import { mkdir, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import type {
Role,
InvokeRequest,
HandoffMessage,
} from "./types.js";
import { ROLES } from "./roles.js";
import { scoreComplexity } from "./complexity.js";
import {
planDecomposition,
type DecompositionPlan,
type SpawnPlan,
} from "./planner.js";
import type { RailsClient } from "./rails-client.js";
import { callLlm } from "./llm.js";
import { buildPrompt } from "./prompts.js";
import { extractCodeBlocks, saveExtractedFiles } from "./code-extractor.js";
import { commitAndPush } from "./git-ops.js";
import {
notifyDiscord,
renderStageStart,
renderStageEnd,
extractDiscordLines,
} from "./discord-notify.js";
// Real LLM call is the default. Set USE_REAL_LLM=false (or the legacy
// RAILS_USE_REAL_LLM=false) to short-circuit every LLM call — useful when
// the operator wants determinism-only smoke tests.
const USE_REAL_LLM =
process.env["USE_REAL_LLM"] !== "false" &&
process.env["RAILS_USE_REAL_LLM"] !== "false";
// Git push auto-activates when a Gitea token is present. Operators can
// force it off (useful for local dry-runs) by setting GIT_PUSH_ENABLED=false.
// The legacy RAILS_ENABLE_GIT_PUSH env var is still honored.
const ENABLE_GIT_PUSH = (() => {
if (process.env["GIT_PUSH_ENABLED"] === "false") return false;
if (process.env["RAILS_ENABLE_GIT_PUSH"] === "false") return false;
if (process.env["GIT_PUSH_ENABLED"] === "true") return true;
if (process.env["RAILS_ENABLE_GIT_PUSH"] === "true") return true;
// Auto: enabled iff we actually have a token to push with
return Boolean(process.env["GITEA_TOKEN"]);
})();
const WORKSPACE_ROOT =
process.env["SISTER_WORKSPACE_DIR"] ??
join(homedir(), "rails-projects");
interface RunContext {
req: InvokeRequest;
rails: RailsClient;
agentName: string;
workspaceDir: string;
/** Aggregated code file paths (relative to pipeline repo root) across all juniors */
producedFiles: string[];
}
/**
* Entry point — score, plan, and execute the hierarchical team.
* Everything is parallelized at each level using Promise.all.
* Each LLM output is also persisted to a file under the pipeline workspace.
*/
export async function executeInvocation(
req: InvokeRequest,
rails: RailsClient,
): Promise<HandoffMessage> {
const agentName = req.agentName || req.stage;
const complexity = scoreComplexity(req.task);
const plan = planDecomposition(complexity);
const workspaceDir = join(WORKSPACE_ROOT, req.pipelineId, req.stage);
await mkdir(workspaceDir, { recursive: true });
const ctx: RunContext = {
req,
rails,
agentName,
workspaceDir,
producedFiles: [],
};
// 1) Manager itself runs first (it is the single root). Its output is the
// strategic decision that feeds into children.
const managerId = ulid();
await rails.createSubTask({
id: managerId,
pipelineId: req.pipelineId,
parentId: null,
role: "manager",
agentName,
title: req.task.title,
description: req.task.description,
complexityScore: complexity.score,
complexityTier: complexity.tier,
model: ROLES.manager.primaryModel,
});
await rails.recordEvent(managerId, "spawned", {
by: "sister-agent",
tier: complexity.tier,
score: complexity.score,
strategy: plan.strategy,
});
await rails.recordEvent(managerId, "started", {});
// Discord stage-start ping (best-effort, fire-and-forget)
if (req.notifyChannelId) {
notifyDiscord({
channelId: req.notifyChannelId,
message: renderStageStart({
agentName,
stage: req.stage,
taskTitle: req.task.title,
}),
})
.then((r) => {
console.log(
JSON.stringify({
ts: new Date().toISOString(),
level: r.ok ? "info" : "warn",
agent: agentName,
msg: "notify.start",
channel: req.notifyChannelId,
ok: r.ok,
error: r.error,
}),
);
})
.catch((err) => {
console.error(
JSON.stringify({
ts: new Date().toISOString(),
level: "error",
agent: agentName,
msg: "notify.start.threw",
error: err instanceof Error ? err.message : String(err),
}),
);
});
} else {
console.log(
JSON.stringify({
ts: new Date().toISOString(),
level: "info",
agent: agentName,
msg: "notify.skipped",
reason: "no notifyChannelId in invoke request",
}),
);
}
try {
const managerWork = await doWork({
role: "manager",
agentName,
stage: req.stage,
taskTitle: req.task.title,
taskDescription: req.task.description,
priorStages: req.priorStages,
});
await persistResult(rails, managerId, managerWork);
const managerPath = await writeOutputFile(
ctx,
managerId,
"manager",
0,
managerWork.text,
);
// 2) Spawn children from plan in parallel (principals / leads / juniors)
const childTexts = await runPlanChildren(
plan,
managerId,
managerWork.text,
ctx,
);
// Git push on implement stage — publishes the workspace to Gitea
let gitResult: Awaited<ReturnType<typeof commitAndPush>> | null = null;
if (ENABLE_GIT_PUSH && req.stage === "implement") {
gitResult = await commitAndPush({
workspaceDir: join(WORKSPACE_ROOT, req.pipelineId),
pipelineId: req.pipelineId,
projectName: req.task.title,
stage: req.stage,
agentName,
});
}
// Deploy stage — derive a preview URL from the implement stage output
let deployUrl = "";
if (req.stage === "deploy") {
deployUrl = derivePreviewUrl(req.priorStages ?? []);
}
await rails.recordEvent(managerId, "completed", {
verdict: "ok",
childCount: childTexts.length,
file: managerPath,
...(gitResult?.ok && {
repoUrl: gitResult.repoUrl,
rawUrlBase: gitResult.rawUrlBase,
commit: gitResult.commit,
filesCount: gitResult.filesCount,
}),
...(gitResult && !gitResult.ok && { gitError: gitResult.error }),
...(deployUrl && { deployUrl }),
});
// Aggregate manager + all child outputs into a single text blob that
// gets passed to the next stage as priorStages. The downstream agent
// (especially the reviewer) needs to see ACTUAL CODE — not a snippet
// — to make a meaningful judgement, so the cap is generous. Cap is
// sized for full HTML/JS/CSS files; LLM context windows are 200k+ so
// 64KB stays well inside budget even after 4 stages of accumulation.
const aggregatedRaw = [managerWork.text, ...childTexts]
.filter(Boolean)
.join("\n\n---\n\n");
// Pull every ```discord-line``` block out before slicing/persisting.
// The first extracted line becomes the stage-end Discord message;
// the cleaned text (with the blocks stripped) is what flows to the
// next stage as priorStages so chat noise doesn't bleed through.
const { lines: discordLines, cleaned: aggregatedClean } =
extractDiscordLines(aggregatedRaw);
const aggregated = aggregatedClean.slice(0, 64_000);
const result = buildSuccessResult(
req.stage,
req.task,
aggregated,
gitResult,
ctx.producedFiles,
);
// Discord stage-end ping (best-effort) — fired AFTER buildSuccessResult
// so the message reflects the ACTUAL verdict ("리뷰 통과" vs "결함 발견"
// vs "배포 실패"). Previously this was emitted before the verdict was
// known, so darang would always say "통과" even when REQUEST_CHANGES.
//
// If a junior LLM emitted a `discord-line` block, use that verbatim
// (it's the LLM speaking in character about its own work). Otherwise
// fall back to the hardcoded persona pool.
if (req.notifyChannelId) {
const verdict = "verdict" in result ? result.verdict : "";
const customLine = discordLines[0] ?? "";
notifyDiscord({
channelId: req.notifyChannelId,
message: renderStageEnd({
agentName,
stage: req.stage,
taskTitle: req.task.title,
verdict,
childCount: childTexts.length,
filesProduced: ctx.producedFiles.length,
...(customLine && { customLine }),
}),
})
.then((r) => {
console.log(
JSON.stringify({
ts: new Date().toISOString(),
level: r.ok ? "info" : "warn",
agent: agentName,
msg: "notify.end",
channel: req.notifyChannelId,
verdict,
ok: r.ok,
error: r.error,
}),
);
})
.catch(() => {
/* swallow */
});
}
return result;
} catch (err) {
const errorReason = err instanceof Error ? err.message : String(err);
await rails.recordEvent(managerId, "failed", { errorReason });
return buildErrorResult(req.stage, errorReason);
}
}
/**
* Run all children defined by `plan.spawn` in parallel.
* Each child may itself spawn grandchildren (also in parallel).
*/
async function runPlanChildren(
plan: DecompositionPlan,
parentId: string,
parentOutput: string,
ctx: RunContext,
): Promise<string[]> {
if (plan.spawn.length === 0 || plan.strategy === "direct") {
return [];
}
const tasks: Array<Promise<string>> = [];
for (const spawnPlan of plan.spawn) {
for (let i = 0; i < spawnPlan.count; i++) {
tasks.push(runSpawnNode(spawnPlan, i, parentId, parentOutput, ctx));
}
}
return Promise.all(tasks);
}
/**
* Execute a single node (principal / lead / junior) and recursively spawn
* its own children (if any) in parallel.
*/
async function runSpawnNode(
spawnPlan: SpawnPlan,
index: number,
parentId: string,
parentOutput: string,
ctx: RunContext,
): Promise<string> {
const id = ulid();
const title = `${spawnPlan.role}-${index + 1}: ${ctx.req.task.title.slice(0, 100)}`;
await ctx.rails.createSubTask({
id,
pipelineId: ctx.req.pipelineId,
parentId,
role: spawnPlan.role,
agentName: ctx.agentName,
title,
description: spawnPlan.rationale,
complexityScore: null,
complexityTier: null,
model: ROLES[spawnPlan.role].primaryModel,
});
await ctx.rails.recordEvent(id, "spawned", {
parent: parentId,
role: spawnPlan.role,
});
await ctx.rails.recordEvent(id, "started", {});
// Do this node's own work first — its output feeds its children
const work = await doWork({
role: spawnPlan.role,
agentName: ctx.agentName,
stage: ctx.req.stage,
taskTitle: title,
taskDescription: spawnPlan.rationale,
parentTitle: ctx.req.task.title,
prevStageOutput: parentOutput,
priorStages: ctx.req.priorStages,
});
await persistResult(ctx.rails, id, work);
const filePath = await writeOutputFile(
ctx,
id,
spawnPlan.role,
index,
work.text,
);
// Extract code blocks and save as real files (implement stage junior)
const extractedFiles = await maybeExtractFiles(ctx, spawnPlan.role, work.text);
// Spawn grandchildren (if any) in parallel
let childTexts: string[] = [];
if (spawnPlan.subBreakdown && spawnPlan.subBreakdown.length > 0) {
const grandTasks: Array<Promise<string>> = [];
for (const grandPlan of spawnPlan.subBreakdown) {
for (let j = 0; j < grandPlan.count; j++) {
grandTasks.push(
runSpawnNode(grandPlan, j, id, work.text, ctx),
);
}
}
childTexts = await Promise.all(grandTasks);
}
await ctx.rails.recordEvent(id, "completed", {
ok: work.ok,
file: filePath,
childCount: childTexts.length,
extractedFiles: extractedFiles.map((f) => ({ path: f.path, lang: f.lang })),
});
return [work.text, ...childTexts].filter(Boolean).join("\n\n");
}
/**
* Parse ```lang:path code blocks from text and save them to the pipeline
* workspace. Only runs for implement-stage junior nodes to keep things scoped.
*/
async function maybeExtractFiles(
ctx: RunContext,
role: Role,
text: string,
): Promise<Array<{ path: string; lang: string; absPath?: string }>> {
if (!text) return [];
// Only juniors in implement stage actually produce code artifacts.
if (role !== "junior") return [];
if (ctx.req.stage !== "implement") return [];
const blocks = extractCodeBlocks(text);
if (blocks.length === 0) return [];
const saved = await saveExtractedFiles(ctx.workspaceDir, blocks);
// Track the repo-relative path (e.g., implement/files/frontend/index.html)
// so the deploy stage can build an accurate preview URL.
for (const f of saved) {
const repoRelPath = `${ctx.req.stage}/files/${f.path}`;
ctx.producedFiles.push(repoRelPath);
}
return saved.map((f) => {
const base: { path: string; lang: string; absPath?: string } = {
path: f.path,
lang: f.lang,
};
if (f.absPath !== undefined) base.absPath = f.absPath;
return base;
});
}
/**
* Call LLM (or stub) to produce the node's work output.
*/
async function doWork(args: {
role: Role;
agentName: string;
stage: InvokeRequest["stage"];
taskTitle: string;
taskDescription: string;
prevStageOutput?: string;
parentTitle?: string;
priorStages?: Array<{ stage: string; text: string }>;
}): Promise<{ ok: boolean; text: string; error?: string }> {
if (!USE_REAL_LLM) {
await new Promise((r) => setTimeout(r, 80));
return { ok: true, text: "" };
}
const prompt = buildPrompt({
role: args.role,
agentName: args.agentName,
stage: args.stage,
taskTitle: args.taskTitle,
taskDescription: args.taskDescription,
...(args.prevStageOutput !== undefined && {
prevStageOutput: args.prevStageOutput,
}),
...(args.parentTitle !== undefined && { parentTitle: args.parentTitle }),
...(args.priorStages !== undefined && { priorStages: args.priorStages }),
});
const result = await callLlm({
prompt,
model: ROLES[args.role].primaryModel,
timeoutMs: 120_000,
});
if (!result.ok) {
return {
ok: false,
text: "",
error: result.errorMessage ?? "unknown LLM error",
};
}
return { ok: true, text: result.text };
}
async function persistResult(
rails: RailsClient,
subTaskId: string,
work: { ok: boolean; text: string; error?: string },
): Promise<void> {
try {
const patch: Record<string, unknown> = {
resultJson: JSON.stringify({ text: work.text, ok: work.ok }),
};
if (work.error) {
patch["errorReason"] = work.error;
}
await rails.patchSubTask(subTaskId, patch);
} catch {
// best-effort — file write still succeeds and LLM output isn't lost
}
}
/**
* Write the LLM output as a file in the pipeline workspace.
* Returns the absolute file path so we can reference it in events/results.
*/
async function writeOutputFile(
ctx: RunContext,
subTaskId: string,
role: Role,
index: number,
text: string,
): Promise<string> {
if (!text) return "";
const shortId = subTaskId.slice(-6);
const fileName = `${role}-${String(index + 1).padStart(2, "0")}-${shortId}.md`;
const fullPath = join(ctx.workspaceDir, fileName);
const header = [
`---`,
`pipeline: ${ctx.req.pipelineId}`,
`stage: ${ctx.req.stage}`,
`agent: ${ctx.agentName}`,
`role: ${role}`,
`subTaskId: ${subTaskId}`,
`createdAt: ${new Date().toISOString()}`,
`---`,
"",
].join("\n");
try {
await writeFile(fullPath, header + text, "utf8");
} catch {
return "";
}
return fullPath;
}
/**
* Walk the prior-stage outputs looking for an implement-stage rawUrlBase,
* then produce a preview URL pointing to the first HTML file (or just the
* repo URL if we can't find one).
*/
function derivePreviewUrl(
priorStages: Array<{ stage: string; text: string }>,
): string {
const impl = priorStages.find((s) => s.stage === "implement");
if (!impl) return "";
const rawBaseMatch = impl.text.match(/rawUrlBase=(\S+)/);
const rawBase = rawBaseMatch?.[1];
if (!rawBase) return "";
// Prefer the exact producedFiles list emitted by implement stage.
const producedMatch = impl.text.match(/producedFiles=([^\n]+)/);
if (producedMatch?.[1]) {
const files = producedMatch[1]
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const html = files.find((f) => f.toLowerCase().endsWith(".html"));
if (html) return `${rawBase}/${html}`;
if (files[0]) return `${rawBase}/${files[0]}`;
}
// Fallback: browse view
return rawBase.replace("/raw/branch/main", "");
}
/**
* Parse the review junior's text output for verdict.
*
* Prompt asks the LLM to start the response with one of:
* APPROVE / REQUEST_CHANGES / ABORT
*
* We scan the entire text (not just the prefix) because the LLM sometimes
* adds a preamble before the verdict keyword. First match wins.
*
* Default = APPROVE only when the text is empty (LLM failure). Otherwise
* if no marker is found we conservatively treat it as REQUEST_CHANGES so
* the pipeline doesn't silently approve unparsable output.
*/
function parseReviewVerdict(text: string): {
verdict: "APPROVE" | "REQUEST_CHANGES" | "ABORT";
reason: string;
} {
if (!text || text.trim().length === 0) {
return { verdict: "APPROVE", reason: "review junior produced no output" };
}
const upper = text.toUpperCase();
// Order matters — REQUEST_CHANGES contains the substring "CHANGES",
// ABORT is the strongest signal, so check ABORT first.
const abortIdx = upper.search(/\bABORT\b/);
const rcIdx = upper.search(/\bREQUEST[_\s-]?CHANGES?\b/);
const approveIdx = upper.search(/\bAPPROVE\b/);
// If both APPROVE and REQUEST_CHANGES appear, the LLM is uncertain —
// bias toward REQUEST_CHANGES so problems aren't silently ignored.
if (abortIdx >= 0 && (rcIdx < 0 || abortIdx < rcIdx)) {
return { verdict: "ABORT", reason: text.slice(0, 16_000) };
}
if (rcIdx >= 0) {
return { verdict: "REQUEST_CHANGES", reason: text.slice(0, 16_000) };
}
if (approveIdx >= 0) {
return { verdict: "APPROVE", reason: "" };
}
// No marker found — conservatively request changes rather than auto-approve
return {
verdict: "REQUEST_CHANGES",
reason: "Reviewer did not emit an APPROVE / REQUEST_CHANGES marker. Raw text:\n" + text.slice(0, 16_000),
};
}
/**
* Parse the deploy junior's text output for verdict.
* Prompt asks the LLM to end with "DEPLOY_DONE" or "DEPLOY_FAILED".
*/
function parseDeployVerdict(text: string): {
verdict: "DEPLOY_DONE" | "DEPLOY_FAILED";
reason: string;
} {
if (!text || text.trim().length === 0) {
return {
verdict: "DEPLOY_FAILED",
reason: "deploy junior produced no output",
};
}
const upper = text.toUpperCase();
const failedIdx = upper.lastIndexOf("DEPLOY_FAILED");
const doneIdx = upper.lastIndexOf("DEPLOY_DONE");
// Take the LAST marker (the prompt asks for it on the final line)
if (failedIdx > doneIdx) {
return { verdict: "DEPLOY_FAILED", reason: text.slice(0, 16_000) };
}
if (doneIdx >= 0) {
return { verdict: "DEPLOY_DONE", reason: "" };
}
// No marker — bias toward FAILED so silent passes don't happen
return {
verdict: "DEPLOY_FAILED",
reason:
"Deployer did not emit a DEPLOY_DONE / DEPLOY_FAILED marker. Raw text:\n" +
text.slice(0, 16_000),
};
}
function buildSuccessResult(
stage: InvokeRequest["stage"],
task: InvokeRequest["task"],
outputText?: string,
gitResult?: {
ok: boolean;
repoUrl: string;
rawUrlBase: string;
commit: string;
filesCount: number;
} | null,
producedFiles: string[] = [],
): HandoffMessage {
// The summary is the payload the next stage will see as priorStages
// text. Reviewer needs to see actual code, not a snippet, so the cap
// matches the upstream aggregation (64KB).
const summary = outputText?.slice(0, 64_000) ?? "";
switch (stage) {
case "plan":
return {
stage: "plan",
verdict: "PLAN_READY",
payload: {
planDir: ".plans",
sprintId: "SPRINT-AUTO",
contractId: "",
},
abortReason: summary ? "" : "",
};
case "implement":
return {
stage: "implement",
verdict: "IMPL_DONE",
payload: {
branch: gitResult?.ok ? "main" : "feature/sister-agent",
commits: gitResult?.ok && gitResult.commit ? [gitResult.commit] : ["llm"],
workdir: task.workdir || "",
selfTestReport: {
summary,
producedFiles,
...(gitResult?.ok && {
repoUrl: gitResult.repoUrl,
rawUrlBase: gitResult.rawUrlBase,
filesCount: gitResult.filesCount,
}),
},
},
errorReason: "",
};
case "review": {
// Test-only override: force a verdict without consulting the LLM.
// Used to verify the FSM review-loop / re-plan paths without
// depending on LLM judgement. Set RAILS_FORCE_REVIEW_VERDICT to
// APPROVE / REQUEST_CHANGES / ABORT on the darang sister-agent
// host. Empty / unset → normal LLM-parsed behavior.
const forced = process.env["RAILS_FORCE_REVIEW_VERDICT"];
if (forced === "REQUEST_CHANGES") {
return {
stage: "review",
verdict: "REQUEST_CHANGES",
payload: {
artifactPath: "",
checklistResults: [],
issues: [
{
severity: "major",
message:
"[forced via RAILS_FORCE_REVIEW_VERDICT] retry-loop test injection",
},
],
},
abortReason: "",
};
}
if (forced === "APPROVE") {
return {
stage: "review",
verdict: "APPROVE",
payload: { artifactPath: "", checklistResults: [], issues: [] },
abortReason: "",
};
}
if (forced === "ABORT") {
return {
stage: "review",
verdict: "ABORT",
payload: { artifactPath: "", checklistResults: [], issues: [] },
abortReason: "[forced] test ABORT",
};
}
const parsed = parseReviewVerdict(summary);
if (parsed.verdict === "APPROVE") {
return {
stage: "review",
verdict: "APPROVE",
payload: {
artifactPath: "",
checklistResults: [],
issues: [],
},
abortReason: "",
};
}
if (parsed.verdict === "REQUEST_CHANGES") {
return {
stage: "review",
verdict: "REQUEST_CHANGES",
payload: {
artifactPath: "",
checklistResults: [],
issues: [
{
severity: "major",
message: parsed.reason,
},
],
},
abortReason: "",
};
}
// ABORT
return {
stage: "review",
verdict: "ABORT",
payload: {
artifactPath: "",
checklistResults: [],
issues: [],
},
abortReason: parsed.reason,
};
}
case "deploy": {
const parsed = parseDeployVerdict(summary);
if (parsed.verdict === "DEPLOY_DONE") {
return {
stage: "deploy",
verdict: "DEPLOY_DONE",
payload: {
deployArtifactPath: "",
projectType: "llm",
verificationResults: { summary },
},
errorReason: "",
};
}
return {
stage: "deploy",
verdict: "DEPLOY_FAILED",
payload: {
deployArtifactPath: "",
projectType: "llm",
verificationResults: { summary, reason: parsed.reason },
},
errorReason: parsed.reason,
};
}
}
}
function buildErrorResult(
stage: InvokeRequest["stage"],
reason: string,
): HandoffMessage {
switch (stage) {
case "plan":
return { stage: "plan", verdict: "ABORT", abortReason: reason };
case "implement":
return { stage: "implement", verdict: "ERROR", errorReason: reason };
case "review":
return { stage: "review", verdict: "ABORT", abortReason: reason };
case "deploy":
return { stage: "deploy", verdict: "DEPLOY_FAILED", errorReason: reason };
}
}

103
sister-agent/src/types.ts Normal file
View File

@@ -0,0 +1,103 @@
import { z } from "zod";
// ── Roles ──
export const Role = z.enum(["manager", "principal", "lead", "junior"]);
export type Role = z.infer<typeof Role>;
// ── Prior stage outputs (for chaining) ──
export const PriorStageOutput = z.object({
stage: z.enum(["plan", "implement", "review", "deploy"]),
text: z.string(),
});
export type PriorStageOutput = z.infer<typeof PriorStageOutput>;
// ── Incoming invoke from rails ──
export const InvokeRequest = z.object({
pipelineId: z.string(),
contractId: z.string().default(""),
stage: z.enum(["plan", "implement", "review", "deploy"]),
task: z.object({
title: z.string(),
description: z.string().default(""),
workdir: z.string().default(""),
}),
priorStages: z.array(PriorStageOutput).default([]),
timeoutMs: z.number().int().positive().default(600_000),
railsApiUrl: z.string().url(),
agentName: z.string().default(""),
/**
* Optional Discord channel ID — propagated from rails so each sister
* can post a stage update to the originating channel via her own
* OpenClaw bot identity.
*/
notifyChannelId: z.string().default(""),
});
export type InvokeRequest = z.infer<typeof InvokeRequest>;
// ── HandoffMessage sent back to rails ──
export const HandoffMessage = z.discriminatedUnion("stage", [
z.object({
stage: z.literal("plan"),
verdict: z.enum(["PLAN_READY", "ABORT"]),
payload: z
.object({
planDir: z.string(),
sprintId: z.string(),
contractId: z.string().default(""),
})
.optional(),
abortReason: z.string().default(""),
}),
z.object({
stage: z.literal("implement"),
verdict: z.enum(["IMPL_DONE", "ERROR"]),
payload: z
.object({
branch: z.string(),
commits: z.array(z.string()),
workdir: z.string().default(""),
selfTestReport: z.record(z.unknown()).default({}),
})
.optional(),
errorReason: z.string().default(""),
}),
z.object({
stage: z.literal("review"),
verdict: z.enum(["APPROVE", "REQUEST_CHANGES", "ABORT"]),
payload: z
.object({
artifactPath: z.string().default(""),
checklistResults: z.array(z.unknown()).default([]),
issues: z.array(z.unknown()).default([]),
})
.optional(),
abortReason: z.string().default(""),
}),
z.object({
stage: z.literal("deploy"),
verdict: z.enum(["DEPLOY_DONE", "DEPLOY_FAILED"]),
payload: z
.object({
deployArtifactPath: z.string().default(""),
projectType: z.string().default(""),
verificationResults: z.record(z.unknown()).default({}),
})
.optional(),
errorReason: z.string().default(""),
}),
]);
export type HandoffMessage = z.infer<typeof HandoffMessage>;
// ── SubTask registration (sent TO rails) ──
export interface SubTaskRecord {
id: string;
pipelineId: string;
parentId: string | null;
role: Role;
agentName: string;
title: string;
description: string;
complexityScore: number | null;
complexityTier: string | null;
model: string;
}

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": false,
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

160
src/cli/doctor.ts Normal file
View File

@@ -0,0 +1,160 @@
import { defineCommand } from "citty";
import { spawn } from "node:child_process";
import { access } from "node:fs/promises";
import { join } from "node:path";
interface CheckResult {
name: string;
ok: boolean;
detail: string;
severity: "error" | "warn" | "info";
}
function runCmd(cmd: string, args: string[]): Promise<{ code: number; out: string }> {
return new Promise((resolveFn) => {
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
let out = "";
child.stdout.on("data", (b: Buffer) => (out += b.toString()));
child.stderr.on("data", (b: Buffer) => (out += b.toString()));
child.on("exit", (code) => resolveFn({ code: code ?? -1, out: out.trim() }));
child.on("error", () => resolveFn({ code: -1, out: "" }));
});
}
async function checkNodeVersion(): Promise<CheckResult> {
const version = process.versions.node;
const major = parseInt(version.split(".")[0] ?? "0", 10);
return {
name: "Node.js",
ok: major >= 22,
detail: `v${version} (require ≥ 22)`,
severity: major >= 22 ? "info" : "error",
};
}
async function checkCommand(
name: string,
cmd: string,
versionArg = "--version",
required = true,
): Promise<CheckResult> {
const r = await runCmd(cmd, [versionArg]);
return {
name,
ok: r.code === 0,
detail: r.code === 0 ? r.out.split("\n")[0] ?? "" : "not found",
severity: r.code === 0 ? "info" : required ? "error" : "warn",
};
}
async function checkEnvVar(
name: string,
required = false,
): Promise<CheckResult> {
const val = process.env[name];
const ok = val !== undefined && val !== "";
return {
name: `env:${name}`,
ok,
detail: ok ? "set" : "not set",
severity: ok ? "info" : required ? "error" : "warn",
};
}
async function checkFile(
name: string,
path: string,
required = false,
): Promise<CheckResult> {
try {
await access(path);
return { name, ok: true, detail: path, severity: "info" };
} catch {
return {
name,
ok: false,
detail: `${path} not found`,
severity: required ? "warn" : "info",
};
}
}
function printResult(r: CheckResult): void {
const mark = r.ok ? "✓" : r.severity === "error" ? "✗" : "⚠";
const color = r.ok ? "\x1b[32m" : r.severity === "error" ? "\x1b[31m" : "\x1b[33m";
const reset = "\x1b[0m";
console.log(` ${color}${mark}${reset} ${r.name.padEnd(24)} ${r.detail}`);
}
export default defineCommand({
meta: {
name: "doctor",
description: "Check rails environment health",
},
args: {
verbose: {
type: "boolean",
alias: "v",
description: "Show passed checks too",
default: false,
},
},
async run({ args }) {
const cwd = process.cwd();
console.log("rails doctor — environment check\n");
const checks: CheckResult[] = [];
// Runtime
console.log("Runtime:");
const runtime = [
await checkNodeVersion(),
await checkCommand("pnpm", "pnpm"),
await checkCommand("git", "git"),
await checkCommand("jq", "jq", "--version", false),
];
runtime.forEach(printResult);
checks.push(...runtime);
// Env vars
console.log("\nEnvironment:");
const envChecks = [
await checkEnvVar("DATABASE_URL", true),
await checkEnvVar("DISCORD_TOKEN", false),
await checkEnvVar("DISCORD_GUILD_ID", false),
await checkEnvVar("GITEA_WEBHOOK_SECRET", false),
];
envChecks.forEach(printResult);
checks.push(...envChecks);
// Project files
console.log("\nProject:");
const files = [
await checkFile("package.json", join(cwd, "package.json"), true),
await checkFile("tsconfig.json", join(cwd, "tsconfig.json"), true),
await checkFile("prisma/schema.prisma", join(cwd, "prisma/schema.prisma"), true),
await checkFile("qa-templates/", join(cwd, "qa-templates"), false),
await checkFile(".env", join(cwd, ".env"), false),
];
files.forEach(printResult);
checks.push(...files);
// Summary
const errors = checks.filter((c) => !c.ok && c.severity === "error").length;
const warns = checks.filter((c) => !c.ok && c.severity === "warn").length;
console.log("");
if (errors === 0 && warns === 0) {
console.log("\x1b[32m✓ All checks passed\x1b[0m");
process.exitCode = 0;
} else if (errors === 0) {
console.log(`\x1b[33m⚠ ${warns} warning(s) — rails can run but some features disabled\x1b[0m`);
process.exitCode = 0;
} else {
console.log(`\x1b[31m✗ ${errors} error(s) and ${warns} warning(s) — fix errors before running\x1b[0m`);
process.exitCode = 1;
}
void args.verbose; // satisfy unused-param lint
},
});

View File

@@ -19,6 +19,10 @@ const main = defineCommand({
run: () => import("./run.js").then((m) => m.default),
resume: () => import("./resume.js").then((m) => m.default),
abort: () => import("./abort.js").then((m) => m.default),
qa: () => import("./qa.js").then((m) => m.default),
doctor: () => import("./doctor.js").then((m) => m.default),
scaffold: () => import("./scaffold.js").then((m) => m.default),
migrate: () => import("./migrate.js").then((m) => m.default),
},
});

203
src/cli/migrate.ts Normal file
View File

@@ -0,0 +1,203 @@
import { defineCommand } from "citty";
import { readdir, stat, readFile } from "node:fs/promises";
import { join } from "node:path";
interface ScanReport {
sourcePath: string;
agents: Array<{ name: string; path: string; size: number }>;
scripts: Array<{ name: string; path: string; portable: boolean; reason: string }>;
workflows: Array<{ name: string; path: string; deprecated: boolean; reason: string }>;
plansDirs: string[];
warnings: string[];
}
const DEPRECATED_WORKFLOWS = [
{
pattern: /\.lobster$/,
reason: "Lobster workflow — LLM-branching, replaced by XState FSM",
},
];
const PORTABLE_SCRIPTS = new Set([
"scaffold.sh",
"install.sh",
"doctor.sh",
"route-task.sh",
]);
const DEPRECATED_SCRIPTS = new Set(["bridge.sh"]);
async function scanDir(root: string, report: ScanReport): Promise<void> {
let entries;
try {
entries = await readdir(root, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const full = join(root, e.name);
if (e.isDirectory()) {
if (e.name === "node_modules" || e.name === ".git") continue;
if (e.name === ".plans") {
report.plansDirs.push(full);
}
await scanDir(full, report);
} else if (e.isFile()) {
if (root.endsWith("/agents") || root.includes("/agents/")) {
if (e.name.endsWith(".md")) {
const s = await stat(full);
report.agents.push({ name: e.name, path: full, size: s.size });
}
}
if (root.endsWith("/scripts") && e.name.endsWith(".sh")) {
if (DEPRECATED_SCRIPTS.has(e.name)) {
report.scripts.push({
name: e.name,
path: full,
portable: false,
reason: "Replaced by discord.js bridge",
});
} else if (PORTABLE_SCRIPTS.has(e.name)) {
report.scripts.push({
name: e.name,
path: full,
portable: true,
reason: "Can be ported to rails command",
});
} else {
report.scripts.push({
name: e.name,
path: full,
portable: true,
reason: "Review manually",
});
}
}
for (const dw of DEPRECATED_WORKFLOWS) {
if (dw.pattern.test(e.name)) {
report.workflows.push({
name: e.name,
path: full,
deprecated: true,
reason: dw.reason,
});
}
}
}
}
}
const fromCmd = defineCommand({
meta: {
name: "from-hanarang-harness",
description: "Scan an existing hanarang-harness directory and report portable assets",
},
args: {
sourcePath: {
type: "positional",
description: "Path to hanarang-harness archive",
required: true,
},
json: {
type: "boolean",
description: "Output as JSON",
default: false,
},
},
async run({ args }) {
const sourcePath = args.sourcePath;
try {
const s = await stat(sourcePath);
if (!s.isDirectory()) {
console.error(`Not a directory: ${sourcePath}`);
process.exitCode = 1;
return;
}
} catch {
console.error(`Path not found: ${sourcePath}`);
process.exitCode = 1;
return;
}
const report: ScanReport = {
sourcePath,
agents: [],
scripts: [],
workflows: [],
plansDirs: [],
warnings: [],
};
await scanDir(sourcePath, report);
// Check for common risky patterns
for (const a of report.agents) {
try {
const content = await readFile(a.path, "utf8");
if (content.includes("xhigh")) {
report.warnings.push(
`${a.name}: references 'xhigh' thinking tier (forbidden in rails)`,
);
}
} catch {
/* ignore */
}
}
if (args.json) {
console.log(JSON.stringify(report, null, 2));
return;
}
console.log(`Migration scan: ${sourcePath}\n`);
console.log(`Agents (${report.agents.length}):`);
for (const a of report.agents.slice(0, 20)) {
console.log(` ${a.name.padEnd(30)} ${a.size} bytes`);
}
if (report.agents.length > 20) {
console.log(` ... and ${report.agents.length - 20} more`);
}
console.log(`\nScripts (${report.scripts.length}):`);
for (const s of report.scripts) {
const mark = s.portable ? "✓" : "✗";
console.log(` ${mark} ${s.name.padEnd(20)} ${s.reason}`);
}
console.log(`\nWorkflows (${report.workflows.length}):`);
for (const w of report.workflows) {
const mark = w.deprecated ? "✗" : "✓";
console.log(` ${mark} ${w.name.padEnd(30)} ${w.reason}`);
}
console.log(`\n.plans/ directories (${report.plansDirs.length}):`);
for (const p of report.plansDirs) {
console.log(` ${p}`);
}
if (report.warnings.length > 0) {
console.log(`\nWarnings (${report.warnings.length}):`);
for (const w of report.warnings) {
console.log(`${w}`);
}
}
console.log("\nNext steps:");
console.log(" 1. Copy portable agents to rails agents/ directory");
console.log(" 2. Replace Lobster workflows with XState FSM (already built-in)");
console.log(" 3. Drop bridge.sh — rails uses discord.js");
console.log(" 4. Wire agent channels in rails.config.yaml");
},
});
export default defineCommand({
meta: {
name: "migrate",
description: "Migration tools for existing hanarang-harness installs",
},
subCommands: {
"from-hanarang-harness": fromCmd,
},
});

107
src/cli/qa.ts Normal file
View File

@@ -0,0 +1,107 @@
import { defineCommand } from "citty";
import { readFile } from "node:fs/promises";
import { loadTemplateForType, listTemplates } from "../qa/template.js";
import { runQaTemplate, saveQaArtifact } from "../qa/runtime.js";
import { QaArtifact } from "../qa/schema.js";
import { join } from "node:path";
const runCmd = defineCommand({
meta: { name: "run", description: "Run QA template against current workdir" },
args: {
type: {
type: "positional",
description: "Sprint type (scaffold, feature, bugfix, refactor, migration, infra)",
required: true,
},
sprintId: {
type: "string",
alias: "s",
description: "Sprint ID",
default: "manual-run",
},
workdir: {
type: "string",
alias: "w",
description: "Working directory (default: cwd)",
default: "",
},
},
async run({ args }) {
const workdir = args.workdir || process.cwd();
const template = await loadTemplateForType(args.type);
const artifact = await runQaTemplate({
template,
workdir,
sprintId: args.sprintId ?? "manual-run",
});
const filePath = await saveQaArtifact(workdir, artifact);
console.log(`QA Artifact: ${artifact.artifactId}`);
console.log(` template: ${artifact.templateId}`);
console.log(` sprintId: ${artifact.sprintId}`);
console.log(` verdict: ${artifact.verdict}`);
console.log(
` summary: ${artifact.summary.passed}/${artifact.summary.total} passed, ${artifact.summary.blockingFailed} blocking failures`,
);
console.log(` path: ${filePath}`);
console.log("");
for (const c of artifact.checks) {
const mark = c.passed ? "✓" : "✗";
const msg = c.passed ? c.evidence : c.errorMessage;
console.log(` ${mark} [${c.severity}] ${c.id}: ${msg}`);
}
process.exitCode =
artifact.verdict === "APPROVE" || artifact.verdict === "APPROVE_WITH_NITS"
? 0
: 1;
},
});
const showCmd = defineCommand({
meta: { name: "show", description: "Show a saved QA artifact" },
args: {
artifactId: {
type: "positional",
description: "Artifact ID",
required: true,
},
},
async run({ args }) {
const filePath = join(
process.cwd(),
".rails",
"qa-artifacts",
`${args.artifactId}.json`,
);
const raw = await readFile(filePath, "utf8");
const artifact = QaArtifact.parse(JSON.parse(raw));
console.log(JSON.stringify(artifact, null, 2));
},
});
const listCmd = defineCommand({
meta: { name: "templates", description: "List available QA templates" },
async run() {
const names = await listTemplates();
if (names.length === 0) {
console.log("No templates found. Check qa-templates/ directory.");
return;
}
console.log("Available QA templates:");
for (const name of names) console.log(` - ${name}`);
},
});
export default defineCommand({
meta: {
name: "qa",
description: "Run QA templates (reviewer stage)",
},
subCommands: {
run: runCmd,
show: showCmd,
templates: listCmd,
},
});

View File

@@ -4,6 +4,7 @@ import { loadConfig } from "../config/loader.js";
import { runPipeline } from "../orchestrator/runner.js";
import { MockTransport } from "../handoff/mock-transport.js";
import type { SisterTransport } from "../handoff/transport.js";
import { buildTransports } from "../handoff/build.js";
import { disconnectPrisma } from "../orchestrator/persist.js";
export default defineCommand({
@@ -39,31 +40,16 @@ export default defineCommand({
loadEnv();
try {
const config = await loadConfig(args.config || undefined);
const transports = new Map<string, SisterTransport>();
let transports: Map<string, SisterTransport>;
if (args.mock) {
// Hard override: force mock across all stages
const mock = new MockTransport();
for (const stage of config.pipeline.stages) {
transports.set(stage, mock);
}
transports = new Map();
for (const stage of config.pipeline.stages) transports.set(stage, mock);
} else {
// For now, default to mock when no real transport wiring is provided.
// Sprint 004 ships DiscordTransport as a class; wiring a live discord
// client is an operator task (see docs/discord-setup.md).
const mock = new MockTransport();
for (const stage of config.pipeline.stages) {
const t = config.agents[stage]?.transport;
if (t === "mock" || !t) {
transports.set(stage, mock);
} else if (t === "discord") {
console.warn(
`[rails] Discord transport for stage '${stage}' requires a bot wiring — falling back to mock.`,
);
transports.set(stage, mock);
} else {
transports.set(stage, mock);
}
}
// Config + env-based transport wiring
transports = buildTransports(config);
}
const result = await runPipeline({

127
src/cli/scaffold.ts Normal file
View File

@@ -0,0 +1,127 @@
import { defineCommand } from "citty";
import { mkdir, writeFile, access } from "node:fs/promises";
import { join, resolve } from "node:path";
const DIRS = [
".plans",
".plans/design",
".plans/sprints",
".plans/migration",
".rails/contracts",
".rails/qa-artifacts",
];
const PLANS_MD = `# Plans.md — {{PROJECT_NAME}}
> 루트 인덱스. 상세는 \`.plans/sprints/*.md\` 참조.
## 📖 관련 문서
- [\`.plans/OVERVIEW.md\`](.plans/OVERVIEW.md) — 전체 목표 / 범위 / 성공 기준
- [\`.plans/design/\`](.plans/design/) — 설계 문서
- [\`.plans/sprints/\`](.plans/sprints/) — 스프린트별 상세
## Sprint 목차
| # | Sprint | 상세 | Status |
|---|---|---|---|
## 마커 범례
| 마커 | 의미 |
|---|---|
| \`cc:TODO\` | 미착수 |
| \`cc:WIP\` | 작업 중 |
| \`cc:blocked\` | 의존 대기 |
| \`cc:완료 [hash]\` | 완료 |
`;
const OVERVIEW_MD = `# {{PROJECT_NAME}} — OVERVIEW
## 목표 (Goal)
TODO: 프로젝트의 한 줄 목표
## 범위 (Scope)
### In Scope
- TODO
### Out of Scope
- TODO
## 성공 기준 (Definition of Done)
1. TODO
## 관련 문서
- \`.plans/sprints/\` — 스프린트 명세
`;
async function pathExists(p: string): Promise<boolean> {
try {
await access(p);
return true;
} catch {
return false;
}
}
export default defineCommand({
meta: {
name: "scaffold",
description: "Generate .plans/ directory structure for a new project",
},
args: {
projectDir: {
type: "positional",
description: "Target directory (default: cwd)",
required: false,
},
name: {
type: "string",
alias: "n",
description: "Project name for templates",
default: "",
},
force: {
type: "boolean",
alias: "f",
description: "Overwrite existing files",
default: false,
},
},
async run({ args }) {
const target = resolve(args.projectDir ?? process.cwd());
const name = args.name || target.split("/").pop() || "project";
console.log(`Scaffolding .plans/ at ${target}`);
for (const d of DIRS) {
await mkdir(join(target, d), { recursive: true });
console.log(` + ${d}/`);
}
const files: Array<[string, string]> = [
["Plans.md", PLANS_MD],
[".plans/OVERVIEW.md", OVERVIEW_MD],
];
for (const [relPath, content] of files) {
const full = join(target, relPath);
if ((await pathExists(full)) && !args.force) {
console.log(` ~ ${relPath} (exists, skipped)`);
continue;
}
const rendered = content.replace(/\{\{PROJECT_NAME\}\}/g, name);
await writeFile(full, rendered, "utf8");
console.log(` + ${relPath}`);
}
console.log("\nDone. Next:");
console.log(" 1. Edit .plans/OVERVIEW.md");
console.log(" 2. Add sprint docs under .plans/sprints/");
console.log(" 3. Reference sprints from Plans.md");
},
});

View File

@@ -1,28 +1,83 @@
import { defineCommand } from "citty";
import { loadEnv } from "../env.js";
import { getLogger } from "../logger.js";
import { startHttpServer } from "../server/http.js";
import { disconnectPrisma } from "../orchestrator/persist.js";
export default defineCommand({
meta: {
name: "serve",
description: "Start the Rails orchestrator server (webhook + Discord bot)",
description: "Start the Rails orchestrator HTTP server",
},
async run() {
args: {
port: {
type: "string",
alias: "p",
description: "HTTP port",
default: "",
},
host: {
type: "string",
alias: "H",
description: "Bind host",
default: "0.0.0.0",
},
config: {
type: "string",
alias: "c",
description: "Path to rails.config.yaml",
default: "",
},
},
async run({ args }) {
const env = loadEnv();
const log = getLogger();
const port = parseInt(args.port || String(env.RAILS_PORT), 10);
// Optional Discord escalation alert config — set on Dev VM via env so
// pipelines that carry a notifyChannelId auto-generate a notifier
// pointed at one of the sister-agent /notify endpoints.
const escalationSisterUrl = process.env["RAILS_NOTIFY_SISTER_URL"] ?? "";
const escalationUserId = process.env["RAILS_NOTIFY_USER_ID"] ?? "";
const escalationConfig = escalationSisterUrl
? {
sisterUrl: escalationSisterUrl,
...(escalationUserId && { userId: escalationUserId }),
}
: undefined;
const { url, close } = await startHttpServer({
port,
host: args.host ?? "0.0.0.0",
...(args.config && { configPath: args.config }),
...(escalationConfig && { escalationConfig }),
});
log.info(
{ port: env.RAILS_PORT, nodeEnv: env.NODE_ENV },
"hanarang-rails starting",
{ url, nodeEnv: env.NODE_ENV },
"hanarang-rails server ready",
);
// TODO (Sprint 004): Discord bot initialization
// TODO (Sprint 004): Gitea webhook HTTP server
// For now, just keep the process alive
log.info("Orchestrator running. Press Ctrl+C to stop.");
const shutdown = async (signal: string) => {
log.info({ signal }, "Shutdown requested");
try {
await close();
await disconnectPrisma();
} catch (err) {
log.error(
{ err: err instanceof Error ? err.message : String(err) },
"Shutdown error",
);
}
process.exit(0);
};
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
await new Promise<never>(() => {
// keep alive until signal
/* block until signal */
});
},
});

View File

@@ -1,14 +1,26 @@
import { z } from "zod";
export const TransportMode = z.enum(["discord", "mock", "local"]);
export const TransportMode = z.enum([
"discord",
"mock",
"local",
"http",
"in-process",
]);
export type TransportMode = z.infer<typeof TransportMode>;
export const AgentConfig = z.object({
role: z.string().min(1),
displayName: z.string().default(""),
/** Sister identity — harang / narang / darang / erang. Required for http / in-process. */
agentName: z.string().default(""),
transport: TransportMode.default("mock"),
channelId: z.string().default(""),
timeoutMs: z.number().int().positive().default(30_000),
/** http transport: the sister-agent daemon endpoint, e.g. http://10.10.10.112:18801 */
endpoint: z.string().default(""),
/** in-process: optional override for the sister-agent core module path */
coreModulePath: z.string().default(""),
timeoutMs: z.number().int().positive().default(600_000),
});
export type AgentConfig = z.infer<typeof AgentConfig>;

149
src/handoff/build.ts Normal file
View File

@@ -0,0 +1,149 @@
import type { SisterTransport } from "./transport.js";
import { MockTransport } from "./mock-transport.js";
import { HttpTransport } from "./http-transport.js";
import { InProcessTransport } from "./in-process-transport.js";
import type { RailsConfig, TransportMode } from "../config/schema.js";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "transport-builder" });
const DEFAULT_SISTER_NAMES: Record<string, string> = {
plan: "harang",
implement: "narang",
review: "darang",
deploy: "erang",
};
/**
* Build a stage → transport map from rails config + environment.
*
* Environment overrides (convenient for docker-compose / smoke tests):
*
* RAILS_TRANSPORT=mock|http|in-process (applies to every stage)
* RAILS_TRANSPORT_{STAGE}=… (per-stage override)
* RAILS_API_URL=http://127.0.0.1:18800 (callback URL for sub-tasks)
*
* For http transport:
* SISTER_ENDPOINT_{STAGE}=http://host:18801
* or legacy RAILS_AGENT_{STAGE}_HOST / _PORT
*
* For in-process transport:
* SISTER_AGENT_CORE_PATH=/abs/path/to/sister-agent/dist/core.js
*
* Sister identity:
* SISTER_NAME_{STAGE}=harang|narang|darang|erang|custom
*/
export function buildTransports(
config: RailsConfig,
): Map<string, SisterTransport> {
const transports = new Map<string, SisterTransport>();
const railsApiUrl =
process.env["RAILS_API_URL"] ?? "http://127.0.0.1:18800";
const sharedMock = new MockTransport();
for (const stage of config.pipeline.stages) {
const agentConfig = config.agents[stage];
const configured = agentConfig?.transport ?? "mock";
const mode = resolveMode(stage, configured);
const agentName = sisterName(stage, agentConfig?.agentName ?? "");
const timeoutMs = agentConfig?.timeoutMs ?? 600_000;
switch (mode) {
case "mock":
case "local":
transports.set(stage, sharedMock);
log.info({ stage, transport: "mock" }, "transport wired");
break;
case "in-process": {
const coreOverride =
agentConfig?.coreModulePath || undefined;
transports.set(
stage,
new InProcessTransport({
agentName,
railsApiUrl,
...(coreOverride ? { coreModulePath: coreOverride } : {}),
timeoutMs,
}),
);
log.info(
{ stage, transport: "in-process", agentName },
"transport wired",
);
break;
}
case "http": {
const endpoint = sisterEndpoint(stage, agentConfig?.endpoint ?? "");
if (!endpoint) {
log.warn(
{ stage },
"http transport requested but endpoint missing — falling back to mock",
);
transports.set(stage, sharedMock);
break;
}
transports.set(
stage,
new HttpTransport({
agentName,
endpoint,
railsApiUrl,
timeoutMs,
}),
);
log.info(
{ stage, transport: "http", endpoint, agentName },
"transport wired",
);
break;
}
case "discord":
log.warn(
{ stage },
"discord transport not wired — falling back to mock",
);
transports.set(stage, sharedMock);
break;
default: {
const exhaustive: never = mode;
void exhaustive;
transports.set(stage, sharedMock);
log.warn({ stage, mode }, "unknown transport — using mock");
}
}
}
return transports;
}
function resolveMode(stage: string, configured: TransportMode): TransportMode {
const perStage = process.env[`RAILS_TRANSPORT_${stage.toUpperCase()}`];
const global = process.env["RAILS_TRANSPORT"];
// Legacy env (kept for backwards compatibility with existing deployments)
const legacy = process.env["RAILS_TRANSPORT_MODE"];
const raw = perStage ?? global ?? (legacy && legacy !== "auto" ? legacy : undefined) ?? configured;
return raw as TransportMode;
}
function sisterEndpoint(stage: string, configured: string): string {
const perStage = process.env[`SISTER_ENDPOINT_${stage.toUpperCase()}`];
if (perStage) return perStage;
if (configured) return configured;
// Legacy host/port style
const host = process.env[`RAILS_AGENT_${stage.toUpperCase()}_HOST`];
const port = process.env[`RAILS_AGENT_${stage.toUpperCase()}_PORT`] ?? "18801";
if (host) return `http://${host}:${port}`;
return "";
}
function sisterName(stage: string, configured: string): string {
if (configured) return configured;
const perStage = process.env[`SISTER_NAME_${stage.toUpperCase()}`];
if (perStage) return perStage;
return DEFAULT_SISTER_NAMES[stage] ?? stage;
}

View File

@@ -0,0 +1,45 @@
import {
CreateSubTaskInput,
SubTaskEventInput,
UpdateSubTaskInput,
createSubTask,
updateSubTask,
recordSubTaskEvent,
} from "../hierarchy/store.js";
/**
* In-process replacement for sister-agent's HTTP-based RailsClient.
*
* When rails runs in single-process mode there's no point going through
* an HTTP loopback to write sub-task events — we can call the store
* directly. This class is duck-type compatible with the sister-agent
* RailsClient (same 3 methods) so InProcessTransport can pass it in place
* of the real client.
*/
export class DirectRailsClient {
async createSubTask(record: unknown): Promise<void> {
const parsed = CreateSubTaskInput.parse(record);
await createSubTask(parsed);
}
async recordEvent(
subTaskId: string,
eventType: string,
payload: Record<string, unknown>,
): Promise<void> {
const parsed = SubTaskEventInput.parse({
subTaskId,
eventType,
payload,
});
await recordSubTaskEvent(parsed);
}
async patchSubTask(
id: string,
patch: Record<string, unknown>,
): Promise<void> {
const parsed = UpdateSubTaskInput.parse(patch);
await updateSubTask(id, parsed);
}
}

View File

@@ -0,0 +1,88 @@
import type { EscalationNotifier } from "../resilience/escalate.js";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "escalation-notifier" });
export interface DiscordEscalationOptions {
/**
* Sister-agent endpoint that owns the Discord bot identity used for the
* alert. Typically harang's sister-agent (port 18801).
*/
sisterUrl: string;
/** Discord channel ID where the alert should land. */
channelId: string;
/** Optional Discord user ID to @-mention in the alert. */
userId?: string;
/** Project name for the message header. */
projectName?: string;
/** Pipeline id (used in formatted message). */
pipelineId: string;
}
/**
* EscalationNotifier that posts a Discord alert via a sister-agent's
* /notify endpoint. The sister-agent then uses its local OpenClaw CLI to
* send the message under that sister's bot identity (so the channel sees
* "하랑이 [bot]" mentioning 자기야 instead of a generic webhook).
*/
export class DiscordEscalationNotifier implements EscalationNotifier {
constructor(private readonly opts: DiscordEscalationOptions) {}
async notify(message: {
title: string;
body: string;
mentionUser?: boolean;
}): Promise<void> {
const mention =
message.mentionUser && this.opts.userId
? `<@${this.opts.userId}> `
: "";
const formatted = [
`${mention}🚨 **자기야, 막혔어** — 사람이 봐야 할 것 같아`,
``,
this.opts.projectName ? `**프로젝트:** ${this.opts.projectName}` : "",
`${message.title}`,
``,
message.body.slice(0, 1500),
``,
`Pipeline ID: \`${this.opts.pipelineId}\``,
`대시보드: https://hanarang.nabomhalang.co.kr/rails`,
]
.filter(Boolean)
.join("\n");
const url = `${this.opts.sisterUrl}/notify`;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30_000);
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
channelId: this.opts.channelId,
message: formatted,
}),
signal: controller.signal,
});
clearTimeout(timer);
if (!res.ok) {
const txt = await res.text();
log.warn(
{ status: res.status, body: txt.slice(0, 200) },
"escalation notify HTTP error",
);
return;
}
log.info(
{ pipelineId: this.opts.pipelineId, channel: this.opts.channelId },
"escalation notify sent",
);
} catch (err) {
log.warn(
{ err: err instanceof Error ? err.message : String(err) },
"escalation notify threw — non-fatal",
);
}
}
}

View File

@@ -0,0 +1,109 @@
import type { SisterTransport, HealthStatus } from "./transport.js";
import { HandoffMessage, type InvokeRequest } from "./message.js";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "http-transport" });
export interface HttpTransportOptions {
agentName: string; // e.g. "harang"
endpoint: string; // e.g. "http://10.10.10.112:18801"
railsApiUrl: string; // callback URL for sub-task events
timeoutMs?: number;
}
/**
* Real HTTP transport — calls a sister-agent daemon on a remote LXC.
* The sister-agent runs the hierarchical sub-agent team and reports
* sub-task events back via railsApiUrl.
*/
export class HttpTransport implements SisterTransport {
readonly name: string;
private readonly opts: Required<HttpTransportOptions>;
constructor(opts: HttpTransportOptions) {
this.name = `http:${opts.agentName}`;
this.opts = {
agentName: opts.agentName,
endpoint: opts.endpoint,
railsApiUrl: opts.railsApiUrl,
timeoutMs: opts.timeoutMs ?? 600_000, // 10 min default
};
}
async invoke(
req: InvokeRequest,
signal?: AbortSignal,
): Promise<HandoffMessage> {
const url = `${this.opts.endpoint}/invoke`;
const payload = {
...req,
agentName: this.opts.agentName,
railsApiUrl: this.opts.railsApiUrl,
timeoutMs: req.timeoutMs || this.opts.timeoutMs,
};
log.info(
{ endpoint: url, stage: req.stage, pipelineId: req.pipelineId },
"HTTP invoke start",
);
// Local timeout controller merged with caller signal
const controller = new AbortController();
const onAbort = (): void => controller.abort();
if (signal) {
if (signal.aborted) controller.abort();
else signal.addEventListener("abort", onAbort, { once: true });
}
const timer = setTimeout(() => controller.abort(), payload.timeoutMs);
try {
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timer);
if (!res.ok) {
const text = await res.text();
throw new Error(`sister-agent ${url} returned ${res.status}: ${text.slice(0, 200)}`);
}
const data = (await res.json()) as unknown;
return HandoffMessage.parse(data);
} catch (err) {
clearTimeout(timer);
if (signal) signal.removeEventListener("abort", onAbort);
throw err;
}
}
async health(): Promise<HealthStatus> {
const start = Date.now();
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
const res = await fetch(`${this.opts.endpoint}/health`, {
signal: controller.signal,
});
clearTimeout(timer);
const latencyMs = Date.now() - start;
return {
alive: res.ok,
latencyMs,
message: res.ok ? "ok" : `status ${res.status}`,
};
} catch (err) {
return {
alive: false,
latencyMs: Date.now() - start,
message: err instanceof Error ? err.message : String(err),
};
}
}
async close(): Promise<void> {
// HTTP client is stateless; no cleanup needed
}
}

View File

@@ -0,0 +1,122 @@
import { pathToFileURL } from "node:url";
import { resolve } from "node:path";
import type { SisterTransport, HealthStatus } from "./transport.js";
import { HandoffMessage, type InvokeRequest } from "./message.js";
import { DirectRailsClient } from "./direct-rails-client.js";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "in-process-transport" });
export interface InProcessTransportOptions {
/** Agent identity — harang / narang / darang / erang (or any custom name). */
agentName: string;
/**
* Rails API URL (loopback). The embedded sister-agent core uses this to
* report sub-task events back via HTTP. Typically "http://127.0.0.1:<port>".
*/
railsApiUrl: string;
/**
* Absolute path to sister-agent's compiled core.js. Defaults to
* env SISTER_AGENT_CORE_PATH
* or <cwd>/sister-agent/dist/core.js
*/
coreModulePath?: string;
timeoutMs?: number;
}
interface SisterCore {
executeInvocation: (req: unknown, rails: unknown) => Promise<unknown>;
}
let cachedCore: Promise<SisterCore> | null = null;
function loadCore(modulePath: string): Promise<SisterCore> {
if (!cachedCore) {
const url = pathToFileURL(resolve(modulePath)).href;
cachedCore = import(url).then((mod: unknown) => {
const m = mod as Partial<SisterCore>;
if (typeof m.executeInvocation !== "function") {
throw new Error(
`sister-agent core module at ${modulePath} is missing executeInvocation export`,
);
}
return m as SisterCore;
});
}
return cachedCore;
}
/**
* InProcessTransport — runs sister-agent logic inside the same Node process
* as rails. Used for single-host, zero-config deployments where spinning up
* 4 separate LXCs is overkill.
*
* Under the hood it dynamically imports sister-agent/dist/core.js and calls
* executeInvocation() directly. Sub-task events still flow through the rails
* HTTP API (loopback) so the observability surface is identical to the
* distributed HTTP transport.
*/
export class InProcessTransport implements SisterTransport {
readonly name: string;
private readonly opts: Required<InProcessTransportOptions>;
constructor(opts: InProcessTransportOptions) {
this.name = `in-process:${opts.agentName}`;
this.opts = {
agentName: opts.agentName,
railsApiUrl: opts.railsApiUrl,
coreModulePath:
opts.coreModulePath ??
process.env["SISTER_AGENT_CORE_PATH"] ??
resolve(process.cwd(), "sister-agent/dist/core.js"),
timeoutMs: opts.timeoutMs ?? 600_000,
};
}
async invoke(
req: InvokeRequest,
signal?: AbortSignal,
): Promise<HandoffMessage> {
const core = await loadCore(this.opts.coreModulePath);
// Use a direct-DB client — skipping HTTP loopback entirely.
const rails = new DirectRailsClient();
const payload = {
...req,
agentName: this.opts.agentName,
railsApiUrl: this.opts.railsApiUrl,
timeoutMs: req.timeoutMs || this.opts.timeoutMs,
};
log.info(
{ agent: this.opts.agentName, stage: req.stage, pipelineId: req.pipelineId },
"in-process invoke start",
);
// The caller's AbortSignal is honored indirectly — executeInvocation
// itself does not take a signal today, but if it hangs the outer pipeline
// timeout will bubble up through the FSM.
void signal;
const raw = await core.executeInvocation(payload, rails);
return HandoffMessage.parse(raw);
}
async health(): Promise<HealthStatus> {
try {
await loadCore(this.opts.coreModulePath);
return { alive: true, latencyMs: 0, message: "ok" };
} catch (err) {
return {
alive: false,
latencyMs: 0,
message: err instanceof Error ? err.message : String(err),
};
}
}
async close(): Promise<void> {
// Nothing to clean up — the module import is cached for the lifetime
// of the process.
}
}

View File

@@ -77,6 +77,12 @@ export const HandoffMessage = z.discriminatedUnion("stage", [
export type HandoffMessage = z.infer<typeof HandoffMessage>;
export const PriorStageOutput = z.object({
stage: z.enum(["plan", "implement", "review", "deploy"]),
text: z.string(),
});
export type PriorStageOutput = z.infer<typeof PriorStageOutput>;
export const InvokeRequest = z.object({
pipelineId: z.string(),
contractId: z.string().default(""),
@@ -88,8 +94,16 @@ export const InvokeRequest = z.object({
description: z.string().default(""),
workdir: z.string().default(""),
}),
priorStages: z.array(PriorStageOutput).default([]),
timeoutMs: z.number().int().positive().default(30_000),
structuredOutput: z.literal(true).default(true),
/**
* Optional Discord channel ID. When set, the sister-agent posts stage
* start / end messages to that channel using its local OpenClaw bot
* identity (so each sister speaks in her own voice in the originating
* channel). Empty string = no Discord notification.
*/
notifyChannelId: z.string().default(""),
});
export type InvokeRequest = z.infer<typeof InvokeRequest>;

173
src/hierarchy/complexity.ts Normal file
View File

@@ -0,0 +1,173 @@
import { z } from "zod";
export const ComplexityTier = z.enum([
"trivial",
"simple",
"moderate",
"complex",
"massive",
]);
export type ComplexityTier = z.infer<typeof ComplexityTier>;
export interface ComplexityScore {
score: number; // 0-100
tier: ComplexityTier;
factors: {
scopeScale: number;
multiDomain: number;
riskKeywords: number;
parallelismHints: number;
uncertainty: number;
estimatedLoc: number;
crossAgentDep: number;
};
matched: string[]; // matched keywords for transparency
}
const SCOPE_RULES: Array<{ re: RegExp; score: number; label: string }> = [
{ re: /한\s*줄|single\s*line|one\s*liner/i, score: 2, label: "one-liner" },
{ re: /single\s*file|한\s*파일|단일\s*파일/i, score: 5, label: "single-file" },
{ re: /컴포넌트|component|small\s*feature|작은\s*기능/i, score: 12, label: "small-feature" },
{ re: /여러\s*파일|multiple\s*files|multi-?file|멀티\s*모듈/i, score: 25, label: "multi-file" },
{ re: /sprint|스프린트/i, score: 40, label: "sprint-scale" },
{ re: /전체\s*리팩터|full\s*refactor|architecture|아키텍처/i, score: 65, label: "architecture" },
{ re: /from\s*scratch|새로\s*만들|scaffold|새\s*프로젝트|new\s*project/i, score: 75, label: "scaffold" },
{ re: /monorepo|migration\s*project|전면\s*재작성/i, score: 85, label: "massive" },
];
const DOMAINS = [
"frontend", "front-end", "프론트",
"backend", "back-end", "백엔드",
"database", "db", "prisma", "postgres", "mariadb", "mysql",
"infra", "deploy", "배포", "docker", "k8s", "kubernetes",
"ci", "cd", "github\\s*actions", "gitea",
"security", "auth", "인증", "oauth",
"test", "테스트", "vitest", "jest",
"api", "rest", "graphql",
];
const RISK_KEYWORDS = [
"migration", "migrate", "마이그레이션",
"breaking", "breaking\\s*change", "호환\\s*안", "하위\\s*호환",
"security", "vulnerability", "취약점",
"auth", "authentication", "authorization",
"data\\s*loss", "데이터\\s*손실", "rollback",
];
const PARALLELISM_HINTS = [
"multiple", "여러", "parallel", "병렬", "동시에", "simultaneously",
"bulk", "대량", "batch", "fanout",
];
const UNCERTAINTY_MARKERS = [
"probably", "maybe", "might", "I\\s*think",
"아직\\s*모르", "아마", "잘\\s*모르", "애매",
];
const LOC_HINT = /(\d{2,})\s*(?:lines?|loc|줄)/i;
const CROSS_AGENT_HINTS = [
/plan.*implement|implement.*review|review.*deploy/i,
/기획.*구현|구현.*리뷰|리뷰.*배포/i,
/전체\s*(?:파이프라인|flow|흐름)/i,
];
function countMatches(text: string, patterns: string[]): {
count: number;
matched: string[];
} {
const matched: string[] = [];
for (const p of patterns) {
const re = new RegExp(`\\b${p}\\b`, "i");
if (re.test(text)) matched.push(p);
}
return { count: matched.length, matched };
}
export function scoreComplexity(task: {
title: string;
description?: string;
}): ComplexityScore {
const text = `${task.title}\n${task.description ?? ""}`;
const matched: string[] = [];
// Scope scale — take the MAX matching rule
let scopeScale = 0;
for (const rule of SCOPE_RULES) {
if (rule.re.test(text)) {
if (rule.score > scopeScale) scopeScale = rule.score;
matched.push(`scope:${rule.label}`);
}
}
if (scopeScale === 0) scopeScale = 10; // unknown default
// Multi-domain
const { count: domainCount, matched: domainMatched } = countMatches(text, DOMAINS);
const multiDomain = Math.min(domainCount * 5, 20);
matched.push(...domainMatched.map((d) => `domain:${d}`));
// Risk keywords
const { count: riskCount, matched: riskMatched } = countMatches(text, RISK_KEYWORDS);
const riskKeywords = Math.min(riskCount * 10, 30);
matched.push(...riskMatched.map((r) => `risk:${r}`));
// Parallelism hints
const { count: parCount, matched: parMatched } = countMatches(text, PARALLELISM_HINTS);
const parallelismHints = Math.min(parCount * 5, 15);
matched.push(...parMatched.map((p) => `parallel:${p}`));
// Uncertainty
const { count: uncertainCount } = countMatches(text, UNCERTAINTY_MARKERS);
const uncertainty = uncertainCount > 0 ? 10 : 0;
if (uncertainty) matched.push("uncertainty");
// Estimated LOC
const locMatch = text.match(LOC_HINT);
const loc = locMatch && locMatch[1] ? parseInt(locMatch[1], 10) : 0;
const estimatedLoc = loc > 500 ? 10 : 0;
if (estimatedLoc) matched.push(`loc:${loc}`);
// Cross-agent dep
let crossAgentDep = 0;
for (const re of CROSS_AGENT_HINTS) {
if (re.test(text)) {
crossAgentDep = 10;
matched.push("cross-agent");
break;
}
}
const score = Math.min(
100,
scopeScale +
multiDomain +
riskKeywords +
parallelismHints +
uncertainty +
estimatedLoc +
crossAgentDep,
);
return {
score,
tier: tierFromScore(score),
factors: {
scopeScale,
multiDomain,
riskKeywords,
parallelismHints,
uncertainty,
estimatedLoc,
crossAgentDep,
},
matched,
};
}
function tierFromScore(score: number): ComplexityTier {
if (score <= 15) return "trivial";
if (score <= 30) return "simple";
if (score <= 50) return "moderate";
if (score <= 75) return "complex";
return "massive";
}

191
src/hierarchy/planner.ts Normal file
View File

@@ -0,0 +1,191 @@
import type { ComplexityScore, ComplexityTier } from "./complexity.js";
import type { Role } from "./roles.js";
export interface SpawnPlan {
role: Role;
count: number;
subBreakdown?: SpawnPlan[]; // nested hierarchy
rationale: string;
}
export interface DecompositionPlan {
tier: ComplexityTier;
score: number;
strategy:
| "direct" // manager executes directly, no spawn
| "single-junior" // 1 junior only
| "lead-team" // 1 lead + juniors
| "principal-team" // 1 principal + leads + juniors
| "fanout"; // massive — 2 principals in parallel
spawn: SpawnPlan[];
notes: string[];
}
/**
* Plan the team structure for a given complexity score.
* Deterministic — no LLM required.
*
* Manager can override this plan if LLM refinement is enabled.
*/
export function planDecomposition(complexity: ComplexityScore): DecompositionPlan {
const { score, tier } = complexity;
switch (tier) {
case "trivial":
return {
tier,
score,
strategy: "direct",
spawn: [],
notes: [
"Manager handles directly — no team needed for trivial tasks.",
],
};
case "simple":
return {
tier,
score,
strategy: "single-junior",
spawn: [
{
role: "junior",
count: 1,
rationale: "Single junior handles the task directly.",
},
],
notes: [],
};
case "moderate":
return {
tier,
score,
strategy: "lead-team",
spawn: [
{
role: "lead",
count: 1,
rationale: "Lead coordinates 2 juniors for moderate scope.",
subBreakdown: [
{
role: "junior",
count: 2,
rationale: "Two juniors execute parallel sub-tasks.",
},
],
},
],
notes: [
"Lead decides the exact sub-task split at runtime.",
],
};
case "complex":
return {
tier,
score,
strategy: "principal-team",
spawn: [
{
role: "principal",
count: 1,
rationale: "Principal handles architecture review + decomposition.",
subBreakdown: [
{
role: "lead",
count: 2,
rationale: "Two leads run parallel workstreams.",
subBreakdown: [
{
role: "junior",
count: 2,
rationale: "Two juniors per lead.",
},
],
},
],
},
],
notes: [
"Principal may bypass lead and spawn juniors directly when bottleneck detected.",
],
};
case "massive":
return {
tier,
score,
strategy: "fanout",
spawn: [
{
role: "principal",
count: 2,
rationale: "Two principals split the work by domain (e.g., FE / BE).",
subBreakdown: [
{
role: "lead",
count: 2,
rationale: "Each principal runs 2 parallel leads.",
subBreakdown: [
{
role: "junior",
count: 3,
rationale: "Three juniors per lead for massive throughput.",
},
],
},
],
},
],
notes: [
"Total worst-case: 2 principals + 4 leads + 12 juniors.",
"Manager monitors and rebalances on escalation.",
],
};
}
}
/**
* Count total nodes in a decomposition plan (for concurrency budgeting).
*/
export function countPlanNodes(plan: DecompositionPlan): number {
const count = (spawns: SpawnPlan[]): number => {
let total = 0;
for (const s of spawns) {
total += s.count;
if (s.subBreakdown) total += s.count * count(s.subBreakdown);
}
return total;
};
// +1 for the manager itself
return 1 + count(plan.spawn);
}
/**
* Check if a plan fits within concurrency budget.
* Returns a trimmed plan if over budget.
*/
export function enforceConcurrencyBudget(
plan: DecompositionPlan,
budget: number,
): DecompositionPlan {
const nodeCount = countPlanNodes(plan);
if (nodeCount <= budget) return plan;
// Over budget — trim sub-breakdowns
const trimmed = JSON.parse(JSON.stringify(plan)) as DecompositionPlan;
const trimFactor = budget / nodeCount;
const trim = (spawns: SpawnPlan[]): void => {
for (const s of spawns) {
s.count = Math.max(1, Math.floor(s.count * trimFactor));
if (s.subBreakdown) trim(s.subBreakdown);
}
};
trim(trimmed.spawn);
trimmed.notes.push(
`Trimmed from ${nodeCount}${countPlanNodes(trimmed)} nodes to fit budget ${budget}.`,
);
return trimmed;
}

60
src/hierarchy/roles.ts Normal file
View File

@@ -0,0 +1,60 @@
import { z } from "zod";
export const Role = z.enum(["manager", "principal", "lead", "junior"]);
export type Role = z.infer<typeof Role>;
export const ROLE_KOREAN: Record<Role, string> = {
manager: "부장",
principal: "수석",
lead: "선임",
junior: "신입",
};
export interface RoleConfig {
primaryModel: string;
fallbackModel: string;
canSpawn: Role[];
maxSpawnPerCall: number;
}
/**
* Default role definitions. Can be overridden by roles.yaml in sister-agent.
*/
export const DEFAULT_ROLE_CONFIG: Record<Role, RoleConfig> = {
manager: {
primaryModel: "gpt-5.4",
fallbackModel: "glm-5.1",
canSpawn: ["principal", "lead", "junior"],
maxSpawnPerCall: 4,
},
principal: {
primaryModel: "gpt-5.4",
fallbackModel: "glm-5.1",
canSpawn: ["lead", "junior"],
maxSpawnPerCall: 3,
},
lead: {
primaryModel: "gpt-codex-5.3",
fallbackModel: "glm-5",
canSpawn: ["junior"],
maxSpawnPerCall: 4,
},
junior: {
primaryModel: "glm-5-turbo",
fallbackModel: "gpt-5",
canSpawn: [],
maxSpawnPerCall: 0,
},
};
export interface ConcurrencyLimits {
default: number;
overrides: Record<string, number>;
}
export const DEFAULT_CONCURRENCY: ConcurrencyLimits = {
default: 8,
overrides: {
narang: 6, // tighter when a build is running
},
};

234
src/hierarchy/store.ts Normal file
View File

@@ -0,0 +1,234 @@
import { z } from "zod";
import { getPrisma } from "../orchestrator/persist.js";
import { Role } from "./roles.js";
import { ComplexityTier } from "./complexity.js";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "sub-task-store" });
export const CreateSubTaskInput = z.object({
id: z.string().min(1),
pipelineId: z.string().min(1),
parentId: z.string().nullable().default(null),
role: Role,
agentName: z.string().min(1),
title: z.string(),
description: z.string().default(""),
complexityScore: z.number().int().nullable().default(null),
complexityTier: ComplexityTier.nullable().default(null),
model: z.string().default(""),
});
export type CreateSubTaskInput = z.infer<typeof CreateSubTaskInput>;
export const SubTaskEventInput = z.object({
subTaskId: z.string().min(1),
eventType: z.enum([
"spawned",
"started",
"progress",
"output",
"completed",
"failed",
"escalated",
]),
payload: z.record(z.unknown()).default({}),
});
export type SubTaskEventInput = z.infer<typeof SubTaskEventInput>;
export const UpdateSubTaskInput = z.object({
state: z
.enum(["queued", "running", "done", "failed", "escalated"])
.optional(),
resultJson: z.string().optional(),
errorReason: z.string().optional(),
startedAt: z.string().datetime().optional(),
completedAt: z.string().datetime().optional(),
});
export type UpdateSubTaskInput = z.infer<typeof UpdateSubTaskInput>;
export async function createSubTask(input: CreateSubTaskInput): Promise<void> {
const prisma = getPrisma();
await prisma.subTask.create({
data: {
id: input.id,
pipelineId: input.pipelineId,
parentId: input.parentId,
role: input.role,
agentName: input.agentName,
title: input.title.slice(0, 500),
description: input.description,
state: "queued",
complexityScore: input.complexityScore,
complexityTier: input.complexityTier,
model: input.model,
},
});
log.info(
{
id: input.id,
role: input.role,
agent: input.agentName,
parent: input.parentId,
},
"Sub-task created",
);
}
export async function updateSubTask(
id: string,
patch: UpdateSubTaskInput,
): Promise<void> {
const prisma = getPrisma();
await prisma.subTask.update({
where: { id },
data: {
...patch,
startedAt: patch.startedAt ? new Date(patch.startedAt) : undefined,
completedAt: patch.completedAt ? new Date(patch.completedAt) : undefined,
},
});
}
export async function recordSubTaskEvent(
input: SubTaskEventInput,
): Promise<void> {
const prisma = getPrisma();
await prisma.subTaskEvent.create({
data: {
subTaskId: input.subTaskId,
eventType: input.eventType,
payloadJson: JSON.stringify(input.payload),
},
});
// Auto-advance state based on event type
const stateMap: Record<string, string | null> = {
started: "running",
completed: "done",
failed: "failed",
escalated: "escalated",
};
const newState = stateMap[input.eventType];
if (newState) {
const patch: UpdateSubTaskInput = { state: newState as UpdateSubTaskInput["state"] };
if (input.eventType === "started") {
patch.startedAt = new Date().toISOString();
} else if (["completed", "failed", "escalated"].includes(input.eventType)) {
patch.completedAt = new Date().toISOString();
}
await prisma.subTask.update({
where: { id: input.subTaskId },
data: {
...patch,
startedAt: patch.startedAt ? new Date(patch.startedAt) : undefined,
completedAt: patch.completedAt ? new Date(patch.completedAt) : undefined,
},
});
}
}
function tryParseJson(s: string): unknown {
try {
return JSON.parse(s);
} catch {
return s;
}
}
export async function getSubTaskDetail(id: string): Promise<unknown | null> {
const prisma = getPrisma();
const node = await prisma.subTask.findUnique({
where: { id },
include: {
events: {
orderBy: { timestamp: "asc" },
select: {
id: true,
eventType: true,
payloadJson: true,
timestamp: true,
},
},
},
});
if (!node) return null;
// Walk up parent chain
const parents: Array<{ id: string; role: string; title: string }> = [];
let cursor: string | null = node.parentId;
while (cursor) {
const p = await prisma.subTask.findUnique({
where: { id: cursor },
select: { id: true, parentId: true, role: true, title: true },
});
if (!p) break;
parents.unshift({ id: p.id, role: p.role, title: p.title });
cursor = p.parentId;
}
// Direct children list
const children = await prisma.subTask.findMany({
where: { parentId: id },
orderBy: { createdAt: "asc" },
select: {
id: true,
role: true,
agentName: true,
title: true,
state: true,
model: true,
startedAt: true,
completedAt: true,
},
});
return {
...node,
parents,
childrenList: children,
events: node.events.map((e) => ({
id: e.id,
eventType: e.eventType,
payload: tryParseJson(e.payloadJson),
timestamp: e.timestamp,
})),
};
}
export async function getSubTaskTree(pipelineId: string): Promise<unknown> {
const prisma = getPrisma();
const all = await prisma.subTask.findMany({
where: { pipelineId },
orderBy: { createdAt: "asc" },
select: {
id: true,
parentId: true,
role: true,
agentName: true,
title: true,
state: true,
complexityScore: true,
complexityTier: true,
model: true,
startedAt: true,
completedAt: true,
createdAt: true,
},
});
// Build tree
const byId = new Map<string, { id: string; parentId: string | null; children: unknown[] } & Record<string, unknown>>();
for (const t of all) {
byId.set(t.id, { ...t, children: [] });
}
const roots: unknown[] = [];
for (const t of all) {
const node = byId.get(t.id)!;
if (t.parentId && byId.has(t.parentId)) {
(byId.get(t.parentId)!.children as unknown[]).push(node);
} else {
roots.push(node);
}
}
return roots;
}

View File

@@ -5,10 +5,23 @@ export const PipelineContext = z.object({
projectName: z.string(),
requirements: z.string().default(""),
currentSprintId: z.string().nullable().default(null),
/** Inner loop: how many times the current plan has been re-implemented */
reviewRound: z.number().int().min(0).default(0),
/** Outer loop: how many times the whole plan→impl→review cycle restarted */
replanCount: z.number().int().min(0).default(0),
retryCount: z.number().int().min(0).default(0),
maxRetries: z.number().int().positive().default(3),
maxReviewRounds: z.number().int().positive().default(3),
/**
* Inner-loop budget. Each round = a real LLM call (30-60s) so we keep
* this small. Total review attempts per plan = 1 + maxReviewRounds.
*/
maxReviewRounds: z.number().int().positive().default(2),
/**
* Outer-loop budget. Total review attempts across the whole pipeline =
* (1+maxReplans)*(1+maxReviewRounds). With defaults (1, 2) = 6 attempts,
* keeping total wall-clock under ~6 min before escalation.
*/
maxReplans: z.number().int().min(0).default(1),
lastError: z.string().nullable().default(null),
contractPath: z.string().nullable().default(null),
createdAt: z.string().datetime(),
@@ -27,9 +40,11 @@ export function createInitialContext(
requirements,
currentSprintId: null,
reviewRound: 0,
replanCount: 0,
retryCount: 0,
maxRetries: 3,
maxReviewRounds: 3,
maxReviewRounds: 2,
maxReplans: 1,
lastError: null,
contractPath: null,
createdAt: new Date().toISOString(),

View File

@@ -19,6 +19,8 @@ export const pipelineMachine = setup({
context.retryCount < context.maxRetries,
canReviewAgain: ({ context }: { context: PipelineContext }) =>
context.reviewRound < context.maxReviewRounds,
canReplan: ({ context }: { context: PipelineContext }) =>
context.replanCount < context.maxReplans,
isRetryable: ({ event }: { event: PipelineEvent }) =>
event.type === "ERROR" && event.retryable === true,
},
@@ -33,6 +35,10 @@ export const pipelineMachine = setup({
context.reviewRound + 1,
}),
resetReviewRound: assign({ reviewRound: 0 }),
incrementReplanCount: assign({
replanCount: ({ context }: { context: PipelineContext }) =>
context.replanCount + 1,
}),
setError: assign({
lastError: ({ event }: { event: PipelineEvent }) =>
event.type === "ERROR" ? event.reason : null,
@@ -56,9 +62,11 @@ export const pipelineMachine = setup({
requirements: "",
currentSprintId: null,
reviewRound: 0,
replanCount: 0,
retryCount: 0,
maxRetries: 3,
maxReviewRounds: 3,
maxReviewRounds: 2,
maxReplans: 1,
lastError: null,
contractPath: null,
createdAt: new Date().toISOString(),
@@ -144,15 +152,35 @@ export const pipelineMachine = setup({
},
REQUEST_CHANGES: [
{
// Inner loop: still have review rounds left → re-implement
// with the same plan
guard: "canReviewAgain",
target: "implementing",
actions: ["incrementReviewRound"],
},
{
// Inner loop exhausted but outer loop still has budget →
// go back to planning. The next plan stage sees the failed
// review issues via priorStages and can produce a new
// approach. reviewRound is reset so the new plan gets a
// fresh review budget.
guard: "canReplan",
target: "planning",
actions: [
"incrementReplanCount",
"resetReviewRound",
assign({
lastError:
"Re-planning after exhausted review rounds — see prior stage feedback",
}),
],
},
{
// Both inner and outer loops exhausted → ask the user
target: "escalated",
actions: [
assign({
lastError: "Max review rounds exceeded",
lastError: "Max replans exceeded — needs human intervention",
}),
],
},

View File

@@ -1,5 +1,5 @@
import { PrismaClient } from "@prisma/client";
import { createActor, type Snapshot } from "xstate";
import { createActor } from "xstate";
import { ulid } from "ulid";
import { pipelineMachine } from "./machine.js";
import { createInitialContext, type PipelineContext } from "./context.js";
@@ -17,6 +17,13 @@ export function getPrisma(): PrismaClient {
return _prisma;
}
/**
* Pipeline persistence layout:
* - pipelines.currentState — XState state value (for quick queries)
* - pipelines.contextJson — FULL persisted snapshot JSON from XState v5
* (includes value, context, status, children, etc.)
*/
export async function createPipeline(
projectName: string,
requirements: string,
@@ -25,20 +32,27 @@ export async function createPipeline(
const pipelineId = ulid();
const ctx = createInitialContext(pipelineId, projectName, requirements);
const actor = createActor(pipelineMachine, {
input: ctx,
});
const actor = createActor(pipelineMachine, { input: ctx });
actor.start();
// Manually set pipelineId into context via assign on fresh start is awkward;
// just store the ctx alongside the persisted snapshot for later restoration.
const persistedSnapshot = actor.getPersistedSnapshot();
const snapshot = actor.getSnapshot();
actor.stop();
// Merge our pipelineId into the persisted context for recovery
const persistedWithId = mergeContextIntoSnapshot(
persistedSnapshot,
ctx,
);
await prisma.pipeline.create({
data: {
id: pipelineId,
projectName,
requirements,
currentState: String(snapshot.value),
contextJson: JSON.stringify(ctx),
contextJson: JSON.stringify(persistedWithId),
},
});
@@ -56,21 +70,22 @@ export async function sendEvent(
where: { id: pipelineId },
});
const ctx = JSON.parse(pipeline.contextJson) as PipelineContext;
const fromState = pipeline.currentState;
const persistedSnapshot = JSON.parse(pipeline.contextJson) as unknown;
// XState v5 accepts a persisted snapshot via the options object.
// We bypass the strict generic typing because the snapshot is produced
// by the same machine and serialised through JSON.
const actor = createActor(pipelineMachine, {
snapshot: {
value: fromState,
context: ctx,
} as unknown as Snapshot<unknown>,
});
snapshot: persistedSnapshot,
} as Parameters<typeof createActor>[1]);
actor.start();
actor.send(event);
const snapshot = actor.getSnapshot();
const toState = String(snapshot.value);
const newContext = snapshot.context as PipelineContext;
const newPersistedSnapshot = actor.getPersistedSnapshot();
actor.stop();
await prisma.$transaction([
@@ -78,7 +93,7 @@ export async function sendEvent(
where: { id: pipelineId },
data: {
currentState: toState,
contextJson: JSON.stringify(newContext),
contextJson: JSON.stringify(newPersistedSnapshot),
},
}),
prisma.stateTransition.create({
@@ -131,13 +146,97 @@ export async function getPipelineState(
if (!pipeline) return null;
const snap = JSON.parse(pipeline.contextJson) as { context?: PipelineContext };
const context =
(snap.context as PipelineContext | undefined) ??
(snap as unknown as PipelineContext);
return {
state: pipeline.currentState as PipelineState,
context: JSON.parse(pipeline.contextJson) as PipelineContext,
context,
transitions: pipeline.transitions,
};
}
/**
* Merge our canonical PipelineContext into the XState persisted snapshot.
* XState v5 snapshots include `.context`, so we overlay our values.
*/
function mergeContextIntoSnapshot(
snapshot: unknown,
ctx: PipelineContext,
): unknown {
if (snapshot && typeof snapshot === "object") {
return { ...(snapshot as object), context: ctx };
}
return snapshot;
}
export async function listTransitions(opts?: {
pipelineId?: string;
eventType?: string;
limit?: number;
}): Promise<
Array<{
id: number;
pipelineId: string;
fromState: string;
toState: string;
eventType: string;
timestamp: Date;
}>
> {
const prisma = getPrisma();
const where: { pipelineId?: string; eventType?: string } = {};
if (opts?.pipelineId) where.pipelineId = opts.pipelineId;
if (opts?.eventType) where.eventType = opts.eventType;
return prisma.stateTransition.findMany({
where,
orderBy: { timestamp: "desc" },
take: opts?.limit ?? 100,
select: {
id: true,
pipelineId: true,
fromState: true,
toState: true,
eventType: true,
timestamp: true,
},
});
}
export async function listEscalations(opts?: {
pipelineId?: string;
resolved?: boolean;
limit?: number;
}): Promise<
Array<{
id: string;
pipelineId: string;
reason: string;
errorCategory: string;
stage: string;
attempts: number;
contextSnapshot: string;
resolvedAt: Date | null;
resolution: string | null;
createdAt: Date;
}>
> {
const prisma = getPrisma();
const where: { pipelineId?: string; resolvedAt?: null | { not: null } } = {};
if (opts?.pipelineId) where.pipelineId = opts.pipelineId;
if (opts?.resolved === false) where.resolvedAt = null;
if (opts?.resolved === true) where.resolvedAt = { not: null };
return prisma.escalation.findMany({
where,
orderBy: { createdAt: "desc" },
take: opts?.limit ?? 50,
});
}
export async function listPipelines(opts?: {
state?: PipelineState;
limit?: number;

View File

@@ -1,5 +1,9 @@
import { ulid } from "ulid";
import { sendEvent, createPipeline } from "./persist.js";
import {
sendEvent,
createPipeline,
getPipelineState,
} from "./persist.js";
import type { PipelineEvent, PipelineState } from "./events.js";
import type { HandoffMessage, InvokeRequest } from "../handoff/message.js";
import type { SisterTransport } from "../handoff/transport.js";
@@ -10,6 +14,46 @@ import { childLogger } from "../logger.js";
const log = childLogger({ module: "runner" });
export type PipelineLifecycleEvent =
| {
type: "started";
pipelineId: string;
projectName: string;
requirements: string;
}
| {
type: "stage-done";
pipelineId: string;
stage: "plan" | "implement" | "review" | "deploy";
text: string;
}
| {
type: "stage-failed";
pipelineId: string;
stage: "plan" | "implement" | "review" | "deploy";
reason: string;
}
| {
type: "completed";
pipelineId: string;
finalState: PipelineState;
transitions: number;
}
| {
type: "failed";
pipelineId: string;
reason: string;
}
| {
type: "escalated";
pipelineId: string;
stage: string;
reason: string;
attempts: number;
};
export type PipelineEventListener = (evt: PipelineLifecycleEvent) => void;
export interface RunOptions {
projectName: string;
requirements: string;
@@ -18,6 +62,20 @@ export interface RunOptions {
signal?: AbortSignal;
maxRetries?: number;
notifier?: EscalationNotifier;
/** Optional lifecycle listener — used by the Discord bridge to post updates. */
onEvent?: PipelineEventListener;
/**
* If provided, resume an already-created pipeline row instead of making
* a new one. Used by async HTTP starts where the caller needs the id
* before runPipeline finishes.
*/
pipelineId?: string;
/**
* Optional Discord channel ID — propagated through every InvokeRequest
* so sister-agents can post stage start/end messages in the originating
* channel using their own OpenClaw bot identity.
*/
notifyChannelId?: string;
}
export interface RunResult {
@@ -32,13 +90,44 @@ export interface RunResult {
* into the FSM until done or escalated.
*/
export async function runPipeline(opts: RunOptions): Promise<RunResult> {
const { pipelineId, state: initialState } = await createPipeline(
opts.projectName,
opts.requirements,
);
let pipelineId: string;
let initialState: string;
if (opts.pipelineId) {
pipelineId = opts.pipelineId;
const existing = await getPipelineState(pipelineId);
if (!existing) {
throw new Error(
`runPipeline: pipelineId ${pipelineId} does not exist in DB`,
);
}
initialState = existing.state;
} else {
const created = await createPipeline(opts.projectName, opts.requirements);
pipelineId = created.pipelineId;
initialState = created.state;
}
log.info({ pipelineId, project: opts.projectName }, "Pipeline run started");
const emit = (evt: PipelineLifecycleEvent): void => {
if (!opts.onEvent) return;
try {
opts.onEvent(evt);
} catch (err) {
log.warn(
{ err: err instanceof Error ? err.message : String(err) },
"pipeline event listener threw",
);
}
};
emit({
type: "started",
pipelineId,
projectName: opts.projectName,
requirements: opts.requirements,
});
// REQUEST event — enters planning
let result = await sendEvent(pipelineId, {
type: "REQUEST",
@@ -47,8 +136,16 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
});
let transitions = 1;
let escalationRecorded = false;
let lastActiveStage: "plan" | "implement" | "review" | "deploy" = "plan";
const TERMINAL: PipelineState[] = ["done", "escalated", "aborted"];
// Accumulate stage outputs so each stage can see what the previous ones produced.
const priorStages: Array<{
stage: "plan" | "implement" | "review" | "deploy";
text: string;
}> = [];
while (!TERMINAL.includes(result.state)) {
if (opts.signal?.aborted) {
result = await sendEvent(pipelineId, {
@@ -64,6 +161,7 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
log.warn({ state: result.state }, "Non-active state encountered, stopping");
break;
}
lastActiveStage = stage;
const transport = opts.transports.get(stage);
if (!transport) {
@@ -89,19 +187,32 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
description: opts.requirements,
workdir: process.cwd(),
},
timeoutMs: opts.config.agents[stage]?.timeoutMs ?? 30_000,
priorStages,
timeoutMs: opts.config.agents[stage]?.timeoutMs ?? 600_000,
structuredOutput: true,
notifyChannelId: opts.notifyChannelId ?? "",
};
const retryResult = await withRetry(
async () => transport.invoke(invokeReq, opts.signal),
{
maxRetries: opts.maxRetries ?? 3,
maxRetries: opts.maxRetries ?? 1,
...(opts.signal && { signal: opts.signal }),
},
);
if (retryResult.ok && retryResult.value) {
// Extract the text output for the next stage
const stageText = extractStageText(retryResult.value);
if (stageText) {
priorStages.push({ stage, text: stageText });
}
emit({
type: "stage-done",
pipelineId,
stage,
text: stageText,
});
const event = handoffToEvent(retryResult.value);
result = await sendEvent(pipelineId, event);
transitions += 1;
@@ -120,6 +231,13 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
"Transport invoke failed after retries",
);
emit({
type: "stage-failed",
pipelineId,
stage,
reason,
});
if (classification && !classification.retryable) {
await recordEscalation(
{
@@ -132,6 +250,14 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
},
opts.notifier,
);
escalationRecorded = true;
emit({
type: "escalated",
pipelineId,
stage,
reason,
attempts: retryResult.attempts,
});
}
result = await sendEvent(pipelineId, {
@@ -149,6 +275,62 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
"Pipeline run finished",
);
if (result.state === "done") {
emit({
type: "completed",
pipelineId,
finalState: result.state,
transitions,
});
} else if (result.state === "escalated") {
// FSM can reach `escalated` two ways:
// 1. ERROR (non-retryable) — recordEscalation was called inline
// and escalationRecorded was set true.
// 2. REQUEST_CHANGES exhaustion (review-loop / replan budget) —
// that's a normal handoff event, not an ERROR, so the inline
// branch above never runs. Catch it here.
if (!escalationRecorded) {
const reason = String(
result.context.lastError ?? "Pipeline escalated",
);
const replanCount = (result.context as { replanCount?: number })
.replanCount ?? 0;
try {
await recordEscalation(
{
pipelineId,
stage: lastActiveStage,
reason,
attempts: replanCount,
contextSnapshot: result.context as unknown as Record<
string,
unknown
>,
},
opts.notifier,
);
} catch (err) {
log.warn(
{ err: err instanceof Error ? err.message : String(err) },
"post-loop recordEscalation failed",
);
}
emit({
type: "escalated",
pipelineId,
stage: lastActiveStage,
reason,
attempts: replanCount,
});
}
} else {
emit({
type: "failed",
pipelineId,
reason: `Pipeline ended in ${result.state}`,
});
}
void initialState; // referenced only for typecheck
return {
pipelineId,
@@ -174,6 +356,57 @@ function mapStateToStage(
}
}
/**
* Extract a text summary from a HandoffMessage for stage chaining.
* sister-agent buildSuccessResult packs summary into selfTestReport/verificationResults.
*/
function extractStageText(h: HandoffMessage): string {
switch (h.stage) {
case "plan":
if (h.payload) {
return `plan dir: ${h.payload.planDir}, sprint: ${h.payload.sprintId}`;
}
return "";
case "implement": {
const report = h.payload?.selfTestReport as
| {
summary?: string;
repoUrl?: string;
rawUrlBase?: string;
filesCount?: number;
producedFiles?: string[];
}
| undefined;
const parts: string[] = [];
if (report?.summary) parts.push(report.summary);
if (report?.repoUrl) parts.push(`[git] repoUrl=${report.repoUrl}`);
if (report?.rawUrlBase) parts.push(`[git] rawUrlBase=${report.rawUrlBase}`);
if (typeof report?.filesCount === "number") {
parts.push(`[git] filesCount=${report.filesCount}`);
}
if (report?.producedFiles && report.producedFiles.length > 0) {
parts.push(`[git] producedFiles=${report.producedFiles.join(",")}`);
}
return parts.join("\n");
}
case "review": {
if (h.payload?.issues && h.payload.issues.length > 0) {
// Generous cap so the next implement loop sees the full reviewer
// critique (not just the first 2KB). Reviewer reason text can be
// multiple paragraphs and the implement junior needs all of it
// to fix the right things.
return `Review ${h.verdict}: ${JSON.stringify(h.payload.issues).slice(0, 32_000)}`;
}
return `Review verdict: ${h.verdict}`;
}
case "deploy": {
const summary =
(h.payload?.verificationResults as { summary?: string } | undefined)?.summary;
return summary ?? "";
}
}
}
function handoffToEvent(h: HandoffMessage): PipelineEvent {
switch (h.stage) {
case "plan":

191
src/qa/runtime.ts Normal file
View File

@@ -0,0 +1,191 @@
import { ulid } from "ulid";
import { writeFile, mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import type { QaTemplate, QaArtifact, QaChecklistResult } from "./schema.js";
import { CHECK_HANDLERS } from "../contract/checks/index.js";
import { computeVerdict, summarize } from "./verdict.js";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "qa-runtime" });
export interface QaRunOptions {
template: QaTemplate;
workdir: string;
sprintId: string;
contractId?: string;
reviewer?: string;
reviewRound?: number;
env?: Record<string, string>;
/**
* Optional resolver for manual checks. If not provided, manual checks
* are marked as SKIPPED (passed=true) which is the default for Sprint 006.
* Sprint 007 or later can plug in an LLM-backed resolver.
*/
manualResolver?: (check: {
id: string;
question: string;
guidance?: string;
}) => Promise<{ passed: boolean; note: string }>;
}
/**
* Run a QA template against a working directory.
* Returns a structured QaArtifact capturing every check result.
*/
export async function runQaTemplate(
opts: QaRunOptions,
): Promise<QaArtifact> {
const startedAt = new Date().toISOString();
const artifactId = ulid();
const env = opts.env ?? (process.env as Record<string, string>);
const allChecks = [
...opts.template.requiredChecks,
...opts.template.additionalChecks,
];
const results: QaChecklistResult[] = [];
for (const check of allChecks) {
const start = Date.now();
if (check.kind === "manual") {
if (opts.manualResolver) {
try {
const spec = check.spec as { question: string; guidance?: string };
const resolved = await opts.manualResolver({
id: check.id,
question: spec.question,
...(spec.guidance !== undefined && { guidance: spec.guidance }),
});
results.push({
id: check.id,
kind: check.kind,
passed: resolved.passed,
severity: check.severity,
evidence: resolved.passed ? resolved.note : "",
errorMessage: resolved.passed ? "" : resolved.note,
reviewerNote: resolved.note,
durationMs: Date.now() - start,
});
} catch (err) {
results.push({
id: check.id,
kind: check.kind,
passed: false,
severity: check.severity,
evidence: "",
errorMessage: `Manual resolver errored: ${err instanceof Error ? err.message : String(err)}`,
reviewerNote: "",
durationMs: Date.now() - start,
});
}
} else {
// Default: SKIPPED
results.push({
id: check.id,
kind: check.kind,
passed: true,
severity: check.severity,
evidence: "[SKIPPED — manual, no resolver]",
errorMessage: "",
reviewerNote: "",
durationMs: Date.now() - start,
});
}
continue;
}
const handler = CHECK_HANDLERS[check.kind];
if (!handler) {
results.push({
id: check.id,
kind: check.kind,
passed: false,
severity: check.severity,
evidence: "",
errorMessage: `No handler for kind: ${check.kind}`,
reviewerNote: "",
durationMs: 0,
});
continue;
}
try {
const outcome = await handler(check, { workdir: opts.workdir, env });
results.push({
id: check.id,
kind: check.kind,
passed: outcome.passed,
severity: check.severity,
evidence: outcome.evidence,
errorMessage: outcome.errorMessage,
reviewerNote: "",
durationMs: outcome.durationMs,
});
} catch (err) {
results.push({
id: check.id,
kind: check.kind,
passed: false,
severity: check.severity,
evidence: "",
errorMessage: `Handler threw: ${err instanceof Error ? err.message : String(err)}`,
reviewerNote: "",
durationMs: Date.now() - start,
});
}
}
const verdict = computeVerdict({
checks: results,
prerequisitesPassed: true,
});
const summary = summarize(results);
const completedAt = new Date().toISOString();
const artifact: QaArtifact = {
schemaVersion: "v1",
artifactId,
sprintId: opts.sprintId,
contractId: opts.contractId ?? "",
templateId: opts.template.template,
reviewer: opts.reviewer ?? "darang",
reviewRound: opts.reviewRound ?? 1,
startedAt,
completedAt,
checks: results,
verdict,
summary,
};
log.info(
{
artifactId,
sprintId: opts.sprintId,
verdict,
...summary,
},
"QA template run complete",
);
return artifact;
}
/**
* Save a QA artifact to disk. Path: .rails/qa-artifacts/<id>.json
*/
export async function saveQaArtifact(
workdir: string,
artifact: QaArtifact,
): Promise<string> {
const filePath = join(
workdir,
".rails",
"qa-artifacts",
`${artifact.artifactId}.json`,
);
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, JSON.stringify(artifact, null, 2), "utf8");
return filePath;
}

61
src/qa/schema.ts Normal file
View File

@@ -0,0 +1,61 @@
import { z } from "zod";
import { DodCheck } from "../contract/schema.js";
/**
* QA Template — a reusable checklist applied during the review stage,
* on top of the sprint contract. Templates are selected by sprint type.
*
* Unlike contracts (which define "done" for the whole sprint), templates
* focus on quality gates the reviewer (darang) must verify.
*/
export const QaTemplate = z.object({
template: z.string().min(1),
version: z.string().default("v1"),
appliesTo: z.array(z.string()).default([]), // sprint types
extends: z.string().optional(), // parent template name
requiredChecks: z.array(DodCheck).default([]),
additionalChecks: z.array(DodCheck).default([]),
});
export type QaTemplate = z.infer<typeof QaTemplate>;
export const QaChecklistResult = z.object({
id: z.string(),
kind: z.string(),
passed: z.boolean(),
severity: z.enum(["critical", "major", "minor", "recommendation"]),
evidence: z.string().default(""),
errorMessage: z.string().default(""),
reviewerNote: z.string().default(""),
durationMs: z.number().default(0),
});
export type QaChecklistResult = z.infer<typeof QaChecklistResult>;
export const QaArtifact = z.object({
schemaVersion: z.literal("v1"),
artifactId: z.string(),
sprintId: z.string(),
contractId: z.string().default(""),
templateId: z.string(),
reviewer: z.string().default("darang"),
reviewRound: z.number().int().min(0).default(1),
startedAt: z.string().datetime(),
completedAt: z.string().datetime(),
checks: z.array(QaChecklistResult),
verdict: z.enum([
"APPROVE",
"APPROVE_WITH_NITS",
"REQUEST_CHANGES",
"ABORT",
]),
summary: z.object({
total: z.number().int().min(0),
passed: z.number().int().min(0),
failed: z.number().int().min(0),
skipped: z.number().int().min(0),
blockingFailed: z.number().int().min(0),
}),
});
export type QaArtifact = z.infer<typeof QaArtifact>;

158
src/qa/template.ts Normal file
View File

@@ -0,0 +1,158 @@
import { readFile, readdir } from "node:fs/promises";
import { join, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parse as parseYaml } from "yaml";
import { QaTemplate } from "./schema.js";
import type { QaTemplate as Template } from "./schema.js";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "qa-template" });
/**
* Locate the qa-templates directory. Priority:
* 1. $RAILS_QA_TEMPLATES_DIR
* 2. ./qa-templates (project root)
* 3. built-in templates next to dist/
*/
export function resolveTemplatesDir(cwd: string = process.cwd()): string {
const envDir = process.env["RAILS_QA_TEMPLATES_DIR"];
if (envDir) return resolve(envDir);
const projectDir = join(cwd, "qa-templates");
return projectDir;
}
export async function loadTemplate(
nameOrPath: string,
templatesDir?: string,
): Promise<Template> {
const dir = templatesDir ?? resolveTemplatesDir();
const candidates = [
nameOrPath,
join(dir, nameOrPath),
join(dir, `${nameOrPath}.yaml`),
join(dir, `${nameOrPath}.yml`),
];
for (const candidate of candidates) {
try {
const raw = await readFile(candidate, "utf8");
const parsed = parseYaml(raw) as unknown;
return QaTemplate.parse(parsed);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err;
}
}
}
throw new Error(
`QA template not found: ${nameOrPath} (searched in ${dir})`,
);
}
/**
* Load template by sprint type, following `extends` chain.
* Example: sprint type 'feature' → feature-v1.yaml
*/
export async function loadTemplateForType(
sprintType: string,
templatesDir?: string,
): Promise<Template> {
const base = await loadTemplate(`${sprintType}-v1`, templatesDir);
return resolveExtends(base, templatesDir);
}
async function resolveExtends(
template: Template,
templatesDir?: string,
seen: Set<string> = new Set(),
): Promise<Template> {
if (!template.extends) return template;
if (seen.has(template.template)) {
throw new Error(
`Circular extends chain in QA template: ${[...seen].join(" → ")}`,
);
}
seen.add(template.template);
const parent = await loadTemplate(template.extends, templatesDir);
const resolved = await resolveExtends(parent, templatesDir, seen);
return {
template: template.template,
version: template.version,
appliesTo: template.appliesTo.length ? template.appliesTo : resolved.appliesTo,
extends: template.extends,
requiredChecks: [...resolved.requiredChecks, ...template.requiredChecks],
additionalChecks: [
...resolved.additionalChecks,
...template.additionalChecks,
],
};
}
/**
* Merge project-specific overrides (qa-extra.yaml) with a base template.
*/
export async function loadProjectExtras(
projectDir: string,
templatesDir?: string,
): Promise<Template | null> {
const extraPath = join(projectDir, "qa-extra.yaml");
try {
const raw = await readFile(extraPath, "utf8");
const parsed = parseYaml(raw) as unknown;
const extra = QaTemplate.parse(parsed);
if (extra.extends) {
return resolveExtends(extra, templatesDir);
}
return extra;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
throw err;
}
}
/**
* Combine a base template with a project-extras template.
*/
export function mergeTemplates(base: Template, extra: Template): Template {
return {
template: `${base.template}+${extra.template}`,
version: base.version,
appliesTo: base.appliesTo,
requiredChecks: [...base.requiredChecks, ...extra.requiredChecks],
additionalChecks: [
...base.additionalChecks,
...extra.additionalChecks,
],
};
}
/**
* List all shipped templates in the templates directory.
*/
export async function listTemplates(
templatesDir?: string,
): Promise<string[]> {
const dir = templatesDir ?? resolveTemplatesDir();
try {
const files = await readdir(dir);
return files
.filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"))
.map((f) => f.replace(/\.ya?ml$/, ""))
.sort();
} catch {
log.warn({ dir }, "Templates directory not found");
return [];
}
}
// For test fixtures and shipped bundle discovery
export const BUILTIN_TEMPLATES_DIR = join(
dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"qa-templates",
);

60
src/qa/verdict.ts Normal file
View File

@@ -0,0 +1,60 @@
import type { QaChecklistResult } from "./schema.js";
export type QaVerdict = "APPROVE" | "APPROVE_WITH_NITS" | "REQUEST_CHANGES" | "ABORT";
export interface VerdictInput {
checks: QaChecklistResult[];
prerequisitesPassed: boolean;
}
/**
* Apply the Harness verdict rules:
* - Any critical/major failure in a blocking check → REQUEST_CHANGES
* - Only minor failures → APPROVE_WITH_NITS
* - Prerequisites failed → ABORT
* - Otherwise → APPROVE
*
* Minor / recommendation issues NEVER cause REQUEST_CHANGES.
* This mirrors the rule documented in .plans/design/qa-template.md.
*/
export function computeVerdict(input: VerdictInput): QaVerdict {
if (!input.prerequisitesPassed) return "ABORT";
const failed = input.checks.filter((c) => !c.passed);
const blockingMajor = failed.filter(
(c) => c.severity === "critical" || c.severity === "major",
);
if (blockingMajor.length > 0) return "REQUEST_CHANGES";
const minorFailed = failed.filter(
(c) => c.severity === "minor" || c.severity === "recommendation",
);
if (minorFailed.length > 0) return "APPROVE_WITH_NITS";
return "APPROVE";
}
export function summarize(
checks: QaChecklistResult[],
): {
total: number;
passed: number;
failed: number;
skipped: number;
blockingFailed: number;
} {
const total = checks.length;
const passed = checks.filter((c) => c.passed).length;
const failed = total - passed;
const skipped = checks.filter((c) =>
c.evidence.toUpperCase().includes("SKIPPED"),
).length;
const blockingFailed = checks.filter(
(c) =>
!c.passed &&
(c.severity === "critical" || c.severity === "major"),
).length;
return { total, passed, failed, skipped, blockingFailed };
}

388
src/server/http.ts Normal file
View File

@@ -0,0 +1,388 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { z } from "zod";
import {
createPipeline,
getPipelineState,
listPipelines,
listTransitions,
listEscalations,
sendEvent,
} from "../orchestrator/persist.js";
import {
runPipeline,
type PipelineEventListener,
} from "../orchestrator/runner.js";
import { loadConfig } from "../config/loader.js";
import type { SisterTransport } from "../handoff/transport.js";
import { buildTransports } from "../handoff/build.js";
import type { EscalationNotifier } from "../resilience/escalate.js";
import { DiscordEscalationNotifier } from "../handoff/escalation-notifier.js";
import {
CreateSubTaskInput,
SubTaskEventInput,
UpdateSubTaskInput,
createSubTask,
recordSubTaskEvent,
updateSubTask,
getSubTaskTree,
getSubTaskDetail,
} from "../hierarchy/store.js";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "http-server" });
const StartRequest = z.object({
project: z.string().min(1),
requirements: z.string().default(""),
mock: z.boolean().default(true),
/**
* Optional Discord channel ID — propagated to each sister-agent so they
* can post stage updates in the originating channel using their own
* OpenClaw bot identity. Set by the harang skill wrapper which extracts
* it from the local sessions.json.
*/
notifyChannelId: z.string().default(""),
});
const AbortRequest = z.object({
reason: z.string().default("aborted via api"),
});
interface ServerOpts {
port: number;
host?: string;
configPath?: string;
/** Optional lifecycle listener injected into every runPipeline call. */
onPipelineEvent?: PipelineEventListener;
/** Optional escalation notifier injected into every runPipeline call. */
notifier?: EscalationNotifier;
/**
* Optional Discord escalation alert config. When set, every pipeline that
* carries a notifyChannelId gets an auto-generated DiscordEscalationNotifier
* pointed at that channel.
*/
escalationConfig?: {
sisterUrl: string;
userId?: string;
};
}
export async function startHttpServer(opts: ServerOpts): Promise<{
close: () => Promise<void>;
url: string;
}> {
const host = opts.host ?? "0.0.0.0";
const config = await loadConfig(opts.configPath);
// Build transport map from config + env (auto picks mock or http)
const transports: Map<string, SisterTransport> = buildTransports(config);
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", `http://${host}`);
const path = url.pathname;
const method = req.method ?? "GET";
log.debug({ method, path }, "Incoming request");
try {
// ── Health ──
if (method === "GET" && path === "/health") {
return sendJson(res, 200, { ok: true, service: "hanarang-rails" });
}
// ── List pipelines ──
if (method === "GET" && path === "/pipelines") {
const limit = parseInt(url.searchParams.get("limit") ?? "20", 10);
const list = await listPipelines({ limit });
return sendJson(res, 200, { pipelines: list });
}
// ── Start new pipeline (synchronous — blocks until done) ──
if (method === "POST" && path === "/pipelines/start") {
const body = await readJson(req);
const parsed = StartRequest.safeParse(body);
if (!parsed.success) {
return sendJson(res, 400, {
error: "invalid_request",
issues: parsed.error.issues,
});
}
const { project, requirements, notifyChannelId } = parsed.data;
// Build a per-pipeline escalation notifier if both escalationConfig
// and a notifyChannelId are present. This wraps opts.notifier so the
// existing manual override still works for callers that pass one.
let activeNotifier: EscalationNotifier | undefined = opts.notifier;
if (opts.escalationConfig && notifyChannelId) {
activeNotifier = new DiscordEscalationNotifier({
sisterUrl: opts.escalationConfig.sisterUrl,
...(opts.escalationConfig.userId && {
userId: opts.escalationConfig.userId,
}),
channelId: notifyChannelId,
projectName: project,
pipelineId: "pending",
});
}
const result = await runPipeline({
projectName: project,
requirements,
config,
transports,
...(notifyChannelId && { notifyChannelId }),
...(opts.onPipelineEvent && { onEvent: opts.onPipelineEvent }),
...(activeNotifier && { notifier: activeNotifier }),
});
return sendJson(res, 201, {
pipelineId: result.pipelineId,
finalState: result.finalState,
transitions: result.transitions,
});
}
// ── Start new pipeline (async — returns pipelineId immediately) ──
//
// Used by the Discord slash command so the bot can ACK within 3 s and
// then post progress updates to a thread as the pipeline advances.
if (method === "POST" && path === "/pipelines/start-async") {
const body = await readJson(req);
const parsed = StartRequest.safeParse(body);
if (!parsed.success) {
return sendJson(res, 400, {
error: "invalid_request",
issues: parsed.error.issues,
});
}
const { project, requirements, notifyChannelId } = parsed.data;
// Create the pipeline row synchronously so we can return its id
// immediately, then run the rest in the background under that id.
const { pipelineId } = await createPipeline(project, requirements);
// Build per-pipeline escalation notifier with the actual pipelineId
let activeNotifier: EscalationNotifier | undefined = opts.notifier;
if (opts.escalationConfig && notifyChannelId) {
activeNotifier = new DiscordEscalationNotifier({
sisterUrl: opts.escalationConfig.sisterUrl,
...(opts.escalationConfig.userId && {
userId: opts.escalationConfig.userId,
}),
channelId: notifyChannelId,
projectName: project,
pipelineId,
});
}
void (async () => {
try {
await runPipeline({
projectName: project,
requirements,
pipelineId,
config,
transports,
...(notifyChannelId && { notifyChannelId }),
...(opts.onPipelineEvent && { onEvent: opts.onPipelineEvent }),
...(activeNotifier && { notifier: activeNotifier }),
});
} catch (err) {
log.error(
{
pipelineId,
err: err instanceof Error ? err.message : String(err),
},
"background pipeline run failed",
);
}
})();
return sendJson(res, 202, {
pipelineId,
status: "accepted",
});
}
// ── Get pipeline status ──
const statusMatch = path.match(/^\/pipelines\/([^/]+)$/);
if (method === "GET" && statusMatch) {
const id = statusMatch[1]!;
const state = await getPipelineState(id);
if (!state) return sendJson(res, 404, { error: "not_found" });
return sendJson(res, 200, state);
}
// ── Abort pipeline ──
const abortMatch = path.match(/^\/pipelines\/([^/]+)\/abort$/);
if (method === "POST" && abortMatch) {
const id = abortMatch[1]!;
const body = await readJson(req);
const parsed = AbortRequest.safeParse(body || {});
const reason = parsed.success
? parsed.data.reason
: "aborted via api";
const result = await sendEvent(id, { type: "ABORT", reason });
return sendJson(res, 200, { id, state: result.state });
}
// ── Create pipeline without running ──
if (method === "POST" && path === "/pipelines") {
const body = await readJson(req);
const parsed = StartRequest.safeParse(body);
if (!parsed.success) {
return sendJson(res, 400, {
error: "invalid_request",
issues: parsed.error.issues,
});
}
const { project, requirements } = parsed.data;
const { pipelineId, state } = await createPipeline(project, requirements);
return sendJson(res, 201, { pipelineId, state });
}
// ── Sub-task creation ──
if (method === "POST" && path === "/api/sub-tasks") {
const body = await readJson(req);
const parsed = CreateSubTaskInput.safeParse(body);
if (!parsed.success) {
return sendJson(res, 400, {
error: "invalid_sub_task",
issues: parsed.error.issues,
});
}
await createSubTask(parsed.data);
return sendJson(res, 201, { id: parsed.data.id });
}
// ── Sub-task update (state, result) ──
const subTaskMatch = path.match(/^\/api\/sub-tasks\/([^/]+)$/);
if (method === "PATCH" && subTaskMatch) {
const id = subTaskMatch[1]!;
const body = await readJson(req);
const parsed = UpdateSubTaskInput.safeParse(body);
if (!parsed.success) {
return sendJson(res, 400, {
error: "invalid_patch",
issues: parsed.error.issues,
});
}
await updateSubTask(id, parsed.data);
return sendJson(res, 200, { id });
}
// ── Sub-task event ──
const eventMatch = path.match(/^\/api\/sub-tasks\/([^/]+)\/events$/);
if (method === "POST" && eventMatch) {
const id = eventMatch[1]!;
const body = await readJson(req);
const parsed = SubTaskEventInput.safeParse({
...(body as Record<string, unknown>),
subTaskId: id,
});
if (!parsed.success) {
return sendJson(res, 400, {
error: "invalid_event",
issues: parsed.error.issues,
});
}
await recordSubTaskEvent(parsed.data);
return sendJson(res, 201, { ok: true });
}
// ── Sub-task tree by pipeline ──
const treeMatch = path.match(/^\/api\/pipelines\/([^/]+)\/sub-tasks$/);
if (method === "GET" && treeMatch) {
const pid = treeMatch[1]!;
const tree = await getSubTaskTree(pid);
return sendJson(res, 200, { pipelineId: pid, tree });
}
// ── Single sub-task detail ──
const detailMatch = path.match(/^\/api\/sub-tasks\/([^/]+)$/);
if (method === "GET" && detailMatch) {
const id = detailMatch[1]!;
const detail = await getSubTaskDetail(id);
if (!detail) return sendJson(res, 404, { error: "not_found" });
return sendJson(res, 200, detail);
}
// ── State transitions (SIEM-style log) ──
if (method === "GET" && path === "/api/transitions") {
const limit = parseInt(url.searchParams.get("limit") ?? "100", 10);
const pid = url.searchParams.get("pipelineId") ?? undefined;
const eventType = url.searchParams.get("eventType") ?? undefined;
const transitions = await listTransitions({
...(pid !== undefined && { pipelineId: pid }),
...(eventType !== undefined && { eventType }),
limit,
});
return sendJson(res, 200, { transitions });
}
// ── Escalations ──
if (method === "GET" && path === "/api/escalations") {
const limit = parseInt(url.searchParams.get("limit") ?? "50", 10);
const pid = url.searchParams.get("pipelineId") ?? undefined;
const resolvedQ = url.searchParams.get("resolved");
const opts: { pipelineId?: string; resolved?: boolean; limit: number } = { limit };
if (pid !== undefined) opts.pipelineId = pid;
if (resolvedQ === "true") opts.resolved = true;
else if (resolvedQ === "false") opts.resolved = false;
const escalations = await listEscalations(opts);
return sendJson(res, 200, { escalations });
}
return sendJson(res, 404, { error: "not_found", path });
} catch (err) {
log.error(
{ err: err instanceof Error ? err.message : String(err) },
"Request handler error",
);
return sendJson(res, 500, {
error: "internal_error",
message: err instanceof Error ? err.message : String(err),
});
}
});
await new Promise<void>((resolveFn) => {
server.listen(opts.port, host, () => resolveFn());
});
const url = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${opts.port}`;
log.info({ url }, "HTTP server listening");
return {
url,
async close() {
await new Promise<void>((resolveFn, rejectFn) => {
server.close((err) => (err ? rejectFn(err) : resolveFn()));
});
},
};
}
function readJson(req: IncomingMessage): Promise<unknown> {
return new Promise((resolveFn, rejectFn) => {
let body = "";
req.on("data", (chunk: Buffer) => (body += chunk.toString()));
req.on("end", () => {
if (!body) return resolveFn({});
try {
resolveFn(JSON.parse(body));
} catch (err) {
rejectFn(err);
}
});
req.on("error", rejectFn);
});
}
function sendJson(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, {
"content-type": "application/json",
"cache-control": "no-store",
});
res.end(JSON.stringify(body));
}

View File

@@ -44,7 +44,9 @@ describe("pipelineMachine", () => {
expect(snapshot.context.reviewRound).toBe(1);
});
it("escalates after max review rounds exceeded", () => {
it("after max review rounds, falls back to planning (re-plan loop)", () => {
// Defaults: maxReviewRounds=2, maxReplans=1.
// Burn through (1 + maxReviewRounds) = 3 review attempts to trigger replan.
const snapshot = runMachine([
{ type: "REQUEST", projectName: "test", requirements: "" },
{ type: "PLAN_READY", planDir: "/tmp", sprintId: "S1" },
@@ -54,15 +56,60 @@ describe("pipelineMachine", () => {
// Round 2 (reviewRound: 1 → 2)
{ type: "IMPL_DONE", branch: "b", commits: ["c2"] },
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] },
// Round 3 (reviewRound: 2 → 3)
// Round 3 reviewRound=2, canReviewAgain (2<2)=false, canReplan (0<1)=true → planning
{ type: "IMPL_DONE", branch: "b", commits: ["c3"] },
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] },
// Round 4 — reviewRound=3, guard 3 < 3 = false → escalated
{ type: "IMPL_DONE", branch: "b", commits: ["c4"] },
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] },
]);
expect(snapshot.value).toBe("planning");
expect(snapshot.context.replanCount).toBe(1);
expect(snapshot.context.reviewRound).toBe(0); // reset on re-plan
expect(snapshot.context.lastError).toContain("Re-planning");
});
it("escalates only after maxReplans + maxReviewRounds both exhausted", () => {
// Defaults: maxReplans=1, maxReviewRounds=2 →
// (1+maxReplans) = 2 plan attempts × (1+maxReviewRounds) = 3 review attempts each
// = 6 total REQUEST_CHANGES events before escalation.
const events: Array<Record<string, unknown>> = [
{ type: "REQUEST", projectName: "test", requirements: "" },
];
for (let plan = 0; plan < 2; plan++) {
events.push({ type: "PLAN_READY", planDir: "/tmp", sprintId: `S${plan}` });
for (let round = 0; round < 3; round++) {
events.push({ type: "IMPL_DONE", branch: "b", commits: [`c${plan}-${round}`] });
events.push({
type: "REQUEST_CHANGES",
issues: [{ severity: "major", message: "fix" }],
});
}
}
const snapshot = runMachine(events);
expect(snapshot.value).toBe("escalated");
expect(snapshot.context.lastError).toContain("review rounds");
expect(snapshot.context.replanCount).toBe(1); // maxReplans = 1
expect(snapshot.context.lastError).toContain("Max replans exceeded");
});
it("re-plan: APPROVE within new plan still leads to deploying", () => {
const events: Array<Record<string, unknown>> = [
{ type: "REQUEST", projectName: "test", requirements: "" },
{ type: "PLAN_READY", planDir: "/tmp", sprintId: "S1" },
];
// Burn through 3 review attempts to trigger first replan
for (let round = 0; round < 3; round++) {
events.push({ type: "IMPL_DONE", branch: "b", commits: [`c${round}`] });
events.push({
type: "REQUEST_CHANGES",
issues: [{ severity: "major", message: "fix" }],
});
}
// Now in planning (replan #1). New plan, then APPROVE on first review.
events.push({ type: "PLAN_READY", planDir: "/tmp/v2", sprintId: "S2" });
events.push({ type: "IMPL_DONE", branch: "b", commits: ["c-new"] });
events.push({ type: "APPROVE", reviewArtifact: "/tmp/r.json" });
events.push({ type: "DEPLOY_DONE", deployArtifact: "/tmp/d.json" });
const snapshot = runMachine(events);
expect(snapshot.value).toBe("done");
expect(snapshot.context.replanCount).toBe(1);
});
it("retryable error goes to retrying, then back (if under limit)", () => {

137
tests/migrate.test.ts Normal file
View File

@@ -0,0 +1,137 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { readdir, stat } from "node:fs/promises";
/**
* Integration-ish test for the migration scanner — we simulate a legacy
* hanarang-harness tree and verify the report includes expected entries.
*
* The CLI is not exercised directly (that would require citty's run()
* plus stdout capture); we instead validate the scanning logic by
* replicating the minimal scanner here.
*/
async function scanArchive(root: string): Promise<{
agents: string[];
scripts: string[];
workflows: string[];
plansDirs: string[];
}> {
const report = {
agents: [] as string[],
scripts: [] as string[],
workflows: [] as string[],
plansDirs: [] as string[],
};
async function walk(dir: string): Promise<void> {
const entries = await readdir(dir, { withFileTypes: true });
for (const e of entries) {
const full = join(dir, e.name);
if (e.isDirectory()) {
if (e.name === "node_modules" || e.name === ".git") continue;
if (e.name === ".plans") report.plansDirs.push(full);
await walk(full);
} else {
if (dir.includes("/agents") && e.name.endsWith(".md")) {
report.agents.push(e.name);
}
if (dir.endsWith("/scripts") && e.name.endsWith(".sh")) {
report.scripts.push(e.name);
}
if (e.name.endsWith(".lobster")) {
report.workflows.push(e.name);
}
}
}
}
await walk(root);
return report;
}
let testDir: string;
beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), "rails-migrate-test-"));
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
});
describe("migration scanner", () => {
it("discovers agents, scripts, workflows, and .plans/", async () => {
// Simulate a legacy harness layout
await mkdir(join(testDir, "agents"), { recursive: true });
await mkdir(join(testDir, "scripts"), { recursive: true });
await mkdir(join(testDir, "workflows"), { recursive: true });
await mkdir(join(testDir, ".plans/sprints"), { recursive: true });
await writeFile(join(testDir, "agents/planner.md"), "# planner");
await writeFile(join(testDir, "agents/reviewer.md"), "# reviewer");
await writeFile(join(testDir, "scripts/scaffold.sh"), "#!/bin/bash");
await writeFile(join(testDir, "scripts/bridge.sh"), "#!/bin/bash");
await writeFile(join(testDir, "scripts/install.sh"), "#!/bin/bash");
await writeFile(join(testDir, "workflows/plan-sprint.lobster"), "plan");
await writeFile(join(testDir, "workflows/review-sprint.lobster"), "review");
await writeFile(join(testDir, ".plans/sprints/SPRINT-001.md"), "# s1");
const report = await scanArchive(testDir);
expect(report.agents).toContain("planner.md");
expect(report.agents).toContain("reviewer.md");
expect(report.scripts).toContain("scaffold.sh");
expect(report.scripts).toContain("bridge.sh");
expect(report.scripts).toContain("install.sh");
expect(report.workflows).toContain("plan-sprint.lobster");
expect(report.workflows).toContain("review-sprint.lobster");
expect(report.plansDirs.length).toBe(1);
});
it("skips node_modules and .git", async () => {
await mkdir(join(testDir, "node_modules/pkg"), { recursive: true });
await mkdir(join(testDir, ".git"), { recursive: true });
await mkdir(join(testDir, "agents"), { recursive: true });
await writeFile(join(testDir, "node_modules/pkg/index.md"), "ignore");
await writeFile(join(testDir, ".git/config"), "ignore");
await writeFile(join(testDir, "agents/real.md"), "keep");
const report = await scanArchive(testDir);
expect(report.agents).toEqual(["real.md"]);
});
it("handles empty archive", async () => {
const report = await scanArchive(testDir);
expect(report.agents).toEqual([]);
expect(report.scripts).toEqual([]);
expect(report.workflows).toEqual([]);
expect(report.plansDirs).toEqual([]);
});
});
describe("scaffold structure", () => {
it("creates expected .plans/ subdirectories", async () => {
const expected = [
".plans",
".plans/design",
".plans/sprints",
".plans/migration",
".rails/contracts",
".rails/qa-artifacts",
];
// Manually create to simulate scaffold
for (const d of expected) {
await mkdir(join(testDir, d), { recursive: true });
}
for (const d of expected) {
const s = await stat(join(testDir, d));
expect(s.isDirectory()).toBe(true);
}
});
});

322
tests/qa.test.ts Normal file
View File

@@ -0,0 +1,322 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { computeVerdict, summarize } from "../src/qa/verdict.js";
import {
loadTemplate,
loadTemplateForType,
listTemplates,
mergeTemplates,
} from "../src/qa/template.js";
import { runQaTemplate, saveQaArtifact } from "../src/qa/runtime.js";
import type { QaTemplate, QaChecklistResult } from "../src/qa/schema.js";
const PROJECT_TEMPLATES = join(process.cwd(), "qa-templates");
let workDir: string;
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "rails-qa-test-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
});
describe("computeVerdict", () => {
const makeCheck = (
passed: boolean,
severity: QaChecklistResult["severity"],
): QaChecklistResult => ({
id: "test",
kind: "manual",
passed,
severity,
evidence: "",
errorMessage: "",
reviewerNote: "",
durationMs: 0,
});
it("APPROVE when all checks pass", () => {
expect(
computeVerdict({
checks: [makeCheck(true, "major"), makeCheck(true, "minor")],
prerequisitesPassed: true,
}),
).toBe("APPROVE");
});
it("REQUEST_CHANGES on any major failure", () => {
expect(
computeVerdict({
checks: [makeCheck(true, "major"), makeCheck(false, "major")],
prerequisitesPassed: true,
}),
).toBe("REQUEST_CHANGES");
});
it("REQUEST_CHANGES on any critical failure", () => {
expect(
computeVerdict({
checks: [makeCheck(false, "critical")],
prerequisitesPassed: true,
}),
).toBe("REQUEST_CHANGES");
});
it("APPROVE_WITH_NITS when only minor issues fail", () => {
expect(
computeVerdict({
checks: [makeCheck(true, "major"), makeCheck(false, "minor")],
prerequisitesPassed: true,
}),
).toBe("APPROVE_WITH_NITS");
});
it("APPROVE_WITH_NITS on recommendation only", () => {
expect(
computeVerdict({
checks: [makeCheck(false, "recommendation")],
prerequisitesPassed: true,
}),
).toBe("APPROVE_WITH_NITS");
});
it("ABORT on prerequisite failure", () => {
expect(
computeVerdict({
checks: [makeCheck(true, "major")],
prerequisitesPassed: false,
}),
).toBe("ABORT");
});
it("NEVER REQUEST_CHANGES for minor-only failures (rule)", () => {
const v = computeVerdict({
checks: [
makeCheck(false, "minor"),
makeCheck(false, "minor"),
makeCheck(false, "recommendation"),
],
prerequisitesPassed: true,
});
expect(v).not.toBe("REQUEST_CHANGES");
});
it("summarize counts blocking failures correctly", () => {
const s = summarize([
makeCheck(true, "major"),
makeCheck(false, "major"),
makeCheck(false, "minor"),
makeCheck(false, "critical"),
]);
expect(s.total).toBe(4);
expect(s.passed).toBe(1);
expect(s.failed).toBe(3);
expect(s.blockingFailed).toBe(2); // major + critical
});
});
describe("template loader", () => {
it("lists shipped templates", async () => {
const names = await listTemplates(PROJECT_TEMPLATES);
expect(names).toContain("scaffold-v1");
expect(names).toContain("feature-v1");
expect(names).toContain("bugfix-v1");
expect(names).toContain("migration-v1");
expect(names).toContain("refactor-v1");
expect(names).toContain("infra-v1");
});
it("loads scaffold-v1 template", async () => {
const t = await loadTemplate("scaffold-v1", PROJECT_TEMPLATES);
expect(t.template).toBe("scaffold-v1");
expect(t.appliesTo).toContain("scaffold");
expect(t.requiredChecks.length).toBeGreaterThan(0);
});
it("loadTemplateForType maps type → template", async () => {
const t = await loadTemplateForType("feature", PROJECT_TEMPLATES);
expect(t.template).toBe("feature-v1");
});
it("throws on unknown template", async () => {
await expect(
loadTemplate("nonexistent", PROJECT_TEMPLATES),
).rejects.toThrow(/not found/);
});
it("merges templates", async () => {
const base: QaTemplate = {
template: "base-v1",
version: "v1",
appliesTo: ["feature"],
requiredChecks: [
{
id: "c1",
description: "",
kind: "file_exists",
spec: { path: "a" },
blocking: true,
severity: "major",
},
],
additionalChecks: [],
};
const extra: QaTemplate = {
template: "extra-v1",
version: "v1",
appliesTo: [],
requiredChecks: [
{
id: "c2",
description: "",
kind: "file_exists",
spec: { path: "b" },
blocking: true,
severity: "major",
},
],
additionalChecks: [],
};
const merged = mergeTemplates(base, extra);
expect(merged.requiredChecks).toHaveLength(2);
expect(merged.template).toBe("base-v1+extra-v1");
});
});
describe("runtime", () => {
it("passes a file_exists check when file present", async () => {
await writeFile(join(workDir, "README.md"), "# test");
const template: QaTemplate = {
template: "test-v1",
version: "v1",
appliesTo: ["scaffold"],
requiredChecks: [
{
id: "readme",
description: "",
kind: "file_exists",
spec: { path: "README.md" },
blocking: true,
severity: "major",
},
],
additionalChecks: [],
};
const artifact = await runQaTemplate({
template,
workdir: workDir,
sprintId: "S1",
});
expect(artifact.verdict).toBe("APPROVE");
expect(artifact.summary.passed).toBe(1);
});
it("fails on missing file", async () => {
const template: QaTemplate = {
template: "test-v1",
version: "v1",
appliesTo: ["scaffold"],
requiredChecks: [
{
id: "missing",
description: "",
kind: "file_exists",
spec: { path: "never.txt" },
blocking: true,
severity: "major",
},
],
additionalChecks: [],
};
const artifact = await runQaTemplate({
template,
workdir: workDir,
sprintId: "S1",
});
expect(artifact.verdict).toBe("REQUEST_CHANGES");
expect(artifact.summary.blockingFailed).toBe(1);
});
it("manual checks are SKIPPED by default (no resolver)", async () => {
const template: QaTemplate = {
template: "test-v1",
version: "v1",
appliesTo: ["feature"],
requiredChecks: [
{
id: "review",
description: "",
kind: "manual",
spec: { question: "Is it good?" },
blocking: true,
severity: "major",
},
],
additionalChecks: [],
};
const artifact = await runQaTemplate({
template,
workdir: workDir,
sprintId: "S1",
});
expect(artifact.verdict).toBe("APPROVE");
expect(artifact.checks[0]!.evidence).toContain("SKIPPED");
});
it("manual checks use resolver when provided", async () => {
const template: QaTemplate = {
template: "test-v1",
version: "v1",
appliesTo: ["feature"],
requiredChecks: [
{
id: "review",
description: "",
kind: "manual",
spec: { question: "Is it clean?" },
blocking: true,
severity: "major",
},
],
additionalChecks: [],
};
const artifact = await runQaTemplate({
template,
workdir: workDir,
sprintId: "S1",
manualResolver: async () => ({ passed: false, note: "found a TODO" }),
});
expect(artifact.verdict).toBe("REQUEST_CHANGES");
expect(artifact.checks[0]!.errorMessage).toBe("found a TODO");
});
it("saves artifact to disk", async () => {
const template: QaTemplate = {
template: "test-v1",
version: "v1",
appliesTo: ["feature"],
requiredChecks: [],
additionalChecks: [],
};
const artifact = await runQaTemplate({
template,
workdir: workDir,
sprintId: "S1",
});
const path = await saveQaArtifact(workDir, artifact);
expect(path).toContain(artifact.artifactId);
});
});
describe("scaffold-v1 on real project", () => {
it("loads without error and has expected checks", async () => {
const t = await loadTemplate("scaffold-v1", PROJECT_TEMPLATES);
const ids = t.requiredChecks.map((c) => c.id);
expect(ids).toContain("readme-exists");
expect(ids).toContain("tsconfig-strict");
});
});

View File

@@ -0,0 +1,128 @@
import { describe, it, expect, afterEach, beforeEach } from "vitest";
import { buildTransports } from "../src/handoff/build.js";
import { RailsConfig } from "../src/config/schema.js";
const baseConfig = (overrides: Partial<{
transport: string;
endpoint: string;
agentName: string;
}>) =>
RailsConfig.parse({
pipeline: { stages: ["plan", "implement", "review", "deploy"] },
agents: {
plan: {
role: "plan",
agentName: overrides.agentName ?? "harang",
transport: overrides.transport ?? "mock",
endpoint: overrides.endpoint ?? "",
},
implement: {
role: "implement",
agentName: "narang",
transport: overrides.transport ?? "mock",
endpoint: overrides.endpoint ?? "",
},
review: {
role: "review",
agentName: "darang",
transport: overrides.transport ?? "mock",
endpoint: overrides.endpoint ?? "",
},
deploy: {
role: "deploy",
agentName: "erang",
transport: overrides.transport ?? "mock",
endpoint: overrides.endpoint ?? "",
},
},
});
const envKeys = [
"RAILS_TRANSPORT",
"RAILS_TRANSPORT_MODE",
"RAILS_TRANSPORT_PLAN",
"RAILS_TRANSPORT_IMPLEMENT",
"RAILS_TRANSPORT_REVIEW",
"RAILS_TRANSPORT_DEPLOY",
"SISTER_ENDPOINT_PLAN",
"SISTER_ENDPOINT_IMPLEMENT",
"SISTER_ENDPOINT_REVIEW",
"SISTER_ENDPOINT_DEPLOY",
"RAILS_AGENT_PLAN_HOST",
"RAILS_AGENT_IMPLEMENT_HOST",
];
describe("transport builder", () => {
const saved: Record<string, string | undefined> = {};
beforeEach(() => {
for (const k of envKeys) {
saved[k] = process.env[k];
delete process.env[k];
}
});
afterEach(() => {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
});
it("defaults to mock when nothing is set", () => {
const config = baseConfig({ transport: "mock" });
const map = buildTransports(config);
expect(map.size).toBe(4);
for (const [, t] of map) expect(t.name).toBe("mock");
});
it("RAILS_TRANSPORT=in-process wires in-process transport everywhere", () => {
process.env["RAILS_TRANSPORT"] = "in-process";
const config = baseConfig({ transport: "mock" });
const map = buildTransports(config);
expect(map.get("plan")?.name).toBe("in-process:harang");
expect(map.get("implement")?.name).toBe("in-process:narang");
expect(map.get("review")?.name).toBe("in-process:darang");
expect(map.get("deploy")?.name).toBe("in-process:erang");
});
it("per-stage RAILS_TRANSPORT_REVIEW overrides global", () => {
process.env["RAILS_TRANSPORT"] = "in-process";
process.env["RAILS_TRANSPORT_REVIEW"] = "mock";
const config = baseConfig({ transport: "mock" });
const map = buildTransports(config);
expect(map.get("plan")?.name).toBe("in-process:harang");
expect(map.get("review")?.name).toBe("mock");
});
it("http transport falls back to mock when no endpoint is configured", () => {
process.env["RAILS_TRANSPORT"] = "http";
const config = baseConfig({ transport: "mock" });
const map = buildTransports(config);
// Without endpoints, everything falls back to mock
for (const [, t] of map) expect(t.name).toBe("mock");
});
it("http transport honors SISTER_ENDPOINT_* env", () => {
process.env["RAILS_TRANSPORT"] = "http";
process.env["SISTER_ENDPOINT_PLAN"] = "http://plan.local:18801";
process.env["SISTER_ENDPOINT_IMPLEMENT"] = "http://impl.local:18801";
process.env["SISTER_ENDPOINT_REVIEW"] = "http://rev.local:18801";
process.env["SISTER_ENDPOINT_DEPLOY"] = "http://dep.local:18801";
const config = baseConfig({ transport: "mock" });
const map = buildTransports(config);
expect(map.get("plan")?.name).toBe("http:harang");
expect(map.get("deploy")?.name).toBe("http:erang");
});
it("legacy RAILS_TRANSPORT_MODE=http + RAILS_AGENT_*_HOST still works", () => {
process.env["RAILS_TRANSPORT_MODE"] = "http";
process.env["RAILS_AGENT_PLAN_HOST"] = "10.0.0.1";
process.env["RAILS_AGENT_IMPLEMENT_HOST"] = "10.0.0.2";
const config = baseConfig({ transport: "mock" });
const map = buildTransports(config);
expect(map.get("plan")?.name).toBe("http:harang");
expect(map.get("implement")?.name).toBe("http:narang");
// review/deploy have no host → fall back to mock
expect(map.get("review")?.name).toBe("mock");
});
});