17 Commits

Author SHA1 Message Date
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
30e782a32d feat(sprint-005): Resilience — retry + backoff + escalation + xhigh 금지
Sprint 005 전체 구현 — F5 (중간 끊김/타임아웃 무한대기) 해결:

Resilience core:
- src/resilience/backoff.ts — exponential backoff + jitter (base 1s, cap 30s)
  + cancellable sleep
- src/resilience/classifier.ts — error → {retryable, reason}
  retryable: timeout, network, rate_limit, transient
  non-retryable: permission, config, invariant(ZodError)
  휴리스틱: ETIMEDOUT/ECONNREFUSED/429 등 메시지 패턴 감지
  + assertAllowedThinkingTier('xhigh' 금지)
- src/resilience/retry.ts — withRetry 래퍼
  non-retryable은 즉시 중단, max retries 초과시 classification 반환
- src/resilience/kill.ts — child process SIGTERM→SIGKILL grace 처리
  + 전역 cleanup handler (SIGINT/SIGTERM)
- src/resilience/escalate.ts — recordEscalation + EscalationNotifier
  + listEscalations / resolveEscalation

Prisma:
- Escalation 모델 추가 (pipelineId, reason, errorCategory, attempts, contextSnapshot)
- Pipeline.escalations 역참조

Runner 통합:
- transport.invoke 를 withRetry 로 래핑
- 실패시 classification 기반으로 자동 escalation 기록 (non-retryable만)
- RunOptions 에 maxRetries / notifier 추가

CLI:
- rails resume <pipeline-id> — escalated → idle 전이
- rails abort <pipeline-id> [-r reason] — 강제 종료

Tests (25 신규, 82 total pass):
- backoff: 기본값/지수/캡/jitter 범위/abort
- classifier: 6 error 클래스 + 3 휴리스틱 + xhigh 금지
- withRetry: 성공/재시도 후 성공/non-retryable 즉시 중단/max 초과/abort

검증: tsc --noEmit ✓ | vitest 82/82 ✓ | build ✓ | CLI ✓

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:40:50 +09:00
b630779909 docs: Sprint 004 완료 마크 + 현재 스프린트 005로 갱신 2026-04-10 15:35:00 +09:00
38c579f177 merge: Sprint 004 — Handoff engine + Transport abstraction (#4) 2026-04-10 15:34:41 +09:00
55 changed files with 6946 additions and 39 deletions

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 합산 로직

View File

@@ -19,16 +19,20 @@
| 1 | 스켈레톤: XState FSM + orchestrator + .plans/ 스캐폴딩 | [SPRINT-001](.plans/sprints/SPRINT-001-skeleton.md) | cc:완료 [PR#1] | | 1 | 스켈레톤: XState FSM + orchestrator + .plans/ 스캐폴딩 | [SPRINT-001](.plans/sprints/SPRINT-001-skeleton.md) | cc:완료 [PR#1] |
| 2 | Skill 강제 진입 hook + bypass 감지 + 차단 | [SPRINT-002](.plans/sprints/SPRINT-002-enforcement.md) | cc:완료 [PR#2] | | 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] | | 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:WIP | | 4 | 4자매 핸드오프 엔진 (상태 전이 기반) | [SPRINT-004](.plans/sprints/SPRINT-004-handoff.md) | cc:완료 [PR#4] |
| 5 | 재시도 / 타임아웃 / 에스컬레이션 policy | [SPRINT-005](.plans/sprints/SPRINT-005-resilience.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:TODO | | 6 | QA 체크리스트 템플릿 + 다랑이 runtime | [SPRINT-006](.plans/sprints/SPRINT-006-qa.md) | cc:완료 [PR#6] |
| 7 | 기존 프로젝트 마이그레이션 (아랑 등) | [SPRINT-007](.plans/sprints/SPRINT-007-migration.md) | cc:TODO | | 7 | 기존 프로젝트 마이그레이션 (아랑 등) | [SPRINT-007](.plans/sprints/SPRINT-007-migration.md) | cc:완료 [PR#7] |
## 현재 스프린트 ## 현재 스프린트
**Sprint 004 — 4자매 핸드오프 엔진 + 디스코드 알림** (`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 실행
## 마커 범례 ## 마커 범례

View File

@@ -68,12 +68,56 @@
## 상태 ## 상태
🚧 **기획 단계**`.plans/` 디렉토리 참조. **v0.1.0**Sprint 000~007 완료. 6가지 실패 모드 전부 코어에서 해결.
자세한 내용: 105 테스트 통과. CLI 13 서브커맨드. 마이그레이션 도구 + QA 6 템플릿 포함.
## 빠른 시작
```bash
# 설치
bash install.sh --repo <repo-url> --dir /path/to/rails
cd /path/to/rails
# 환경 확인
pnpm rails doctor
# .env 설정 후 DB 마이그레이션
cp .env.example .env
# DATABASE_URL 등 채우기
pnpm prisma migrate deploy
# Mock 모드로 E2E 스모크 테스트
pnpm rails run hello-world --mock -r "Try a pipeline"
pnpm rails status
```
## 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` | 스킬 강제 진입 |
| `rails skill-trace show/blocked` | 도구 사용 감사 로그 |
| `rails doctor` | 환경 헬스체크 |
| `rails scaffold` | 신규 프로젝트 `.plans/` 생성 |
| `rails migrate from-hanarang-harness <path>` | 레거시 하네스 스캔 |
| `rails serve` | 오케스트레이터 서버 (v0.2 완성 예정) |
## 문서
- [`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/OVERVIEW.md`](.plans/OVERVIEW.md) — 프로젝트 전체 개요
- [`.plans/failure-audit.md`](.plans/failure-audit.md) — 실패 감사 - [`.plans/failure-audit.md`](.plans/failure-audit.md) — F1F6 실패 감사
- [`.plans/design/`](.plans/design/) — 설계 문서 - [`.plans/design/`](.plans/design/) — 설계 문서 9종
- [`.plans/sprints/`](.plans/sprints/) — 스프린트 상세 - [`.plans/sprints/`](.plans/sprints/) — 스프린트 상세
## 라이선스 ## 라이선스

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

@@ -19,6 +19,8 @@ model Pipeline {
transitions StateTransition[] transitions StateTransition[]
actorSpawns ActorSpawn[] actorSpawns ActorSpawn[]
contracts Contract[] contracts Contract[]
escalations Escalation[]
subTasks SubTask[]
@@index([currentState]) @@index([currentState])
@@index([createdAt]) @@index([createdAt])
@@ -72,3 +74,65 @@ model Contract {
@@index([sprintId]) @@index([sprintId])
@@map("contracts") @@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)
reason String @db.VarChar(500)
errorCategory String @db.VarChar(50)
stage String @db.VarChar(50) @default("")
attempts Int @default(0)
contextSnapshot String @db.LongText
resolvedAt DateTime?
resolution String? @db.VarChar(50)
createdAt DateTime @default(now())
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
@@index([pipelineId])
@@index([createdAt])
@@map("escalations")
}

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 @@
1775808589

View File

@@ -0,0 +1,7 @@
{
"timestamp": "2026-04-10T08:10:32Z",
"changed_file": "/home/erang/hanarang-rails/src/server/http.ts",
"test_command": "npm test",
"related_test": "",
"recommendation": "テストの実行を推奨します"
}

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,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";
}

View File

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

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

@@ -0,0 +1,191 @@
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":
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;
}

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;
}
}
}

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

@@ -0,0 +1,42 @@
import type { Role } from "./types.js";
export interface RoleConfig {
primaryModel: string;
fallbackModel: string;
canSpawn: Role[];
maxSpawnPerCall: number;
}
export const ROLES: 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 const ROLE_KOREAN: Record<Role, string> = {
manager: "부장",
principal: "수석",
lead: "선임",
junior: "신입",
};

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

@@ -0,0 +1,117 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { InvokeRequest } from "./types.js";
import { executeInvocation } from "./spawn.js";
import { RailsClient } from "./rails-client.js";
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,
});
}
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,
});
}
const req2 = { ...parsed.data, agentName: parsed.data.agentName || AGENT_NAME };
const railsClient = new RailsClient(req2.railsApiUrl);
log("info", "invoke.start", {
pipelineId: req2.pipelineId,
stage: req2.stage,
});
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"));

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

@@ -0,0 +1,287 @@
import { ulid } from "ulid";
import type { Role, SubTaskRecord, InvokeRequest, HandoffMessage } from "./types.js";
import { ROLES } from "./roles.js";
import { scoreComplexity, type ComplexityScore } from "./complexity.js";
import { planDecomposition, type DecompositionPlan } from "./planner.js";
import type { RailsClient } from "./rails-client.js";
/**
* Execute an invocation using the hierarchical team strategy.
*
* Current implementation is a **simulation-only** executor: it creates
* the full sub-task tree in rails DB and streams events, but does not
* actually call LLMs. This gives us the full observable hierarchy without
* requiring openclaw CLI integration to be wired up yet.
*
* Swap in real LLM calls by replacing executeRole().
*/
export async function executeInvocation(
req: InvokeRequest,
rails: RailsClient,
): Promise<HandoffMessage> {
const agentName = req.agentName || req.stage;
// Step 1: score complexity
const complexity = scoreComplexity(req.task);
// Step 2: plan decomposition
const plan = planDecomposition(complexity);
// Step 3: create the manager (root) sub-task
const managerId = ulid();
const managerRecord: SubTaskRecord = {
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.createSubTask(managerRecord);
await rails.recordEvent(managerId, "spawned", {
by: "sister-agent",
tier: complexity.tier,
score: complexity.score,
});
await rails.recordEvent(managerId, "started", {
strategy: plan.strategy,
nodeCount: countPlanNodes(plan),
});
// Step 4: execute the plan recursively
try {
const result = await executeRole(
"manager",
managerId,
req,
plan,
complexity,
rails,
agentName,
0,
);
await rails.recordEvent(managerId, "completed", {
verdict: result.verdict,
});
return result;
} catch (err) {
const errorReason = err instanceof Error ? err.message : String(err);
await rails.recordEvent(managerId, "failed", { errorReason });
return buildErrorResult(req.stage, errorReason);
}
}
/**
* Execute a single role node (recursive).
* Spawns children if the plan calls for it, aggregates their results.
*/
async function executeRole(
role: Role,
selfId: string,
req: InvokeRequest,
plan: DecompositionPlan,
complexity: ComplexityScore,
rails: RailsClient,
agentName: string,
depth: number,
): Promise<HandoffMessage> {
// If no children planned for this role, execute directly
const hasChildren =
depth === 0 && plan.spawn.length > 0 && plan.strategy !== "direct";
if (!hasChildren) {
// Leaf execution — in this simulation we just produce a success result
await simulateWork(role);
return buildSuccessResult(req.stage, req.task);
}
// Spawn children per plan
for (const spawnPlan of plan.spawn) {
for (let i = 0; i < spawnPlan.count; i++) {
const childId = ulid();
const childRecord: SubTaskRecord = {
id: childId,
pipelineId: req.pipelineId,
parentId: selfId,
role: spawnPlan.role,
agentName,
title: `${spawnPlan.role}-${i + 1}: ${req.task.title.slice(0, 100)}`,
description: spawnPlan.rationale,
complexityScore: null,
complexityTier: null,
model: ROLES[spawnPlan.role].primaryModel,
};
await rails.createSubTask(childRecord);
await rails.recordEvent(childId, "spawned", {
parent: selfId,
role: spawnPlan.role,
});
await rails.recordEvent(childId, "started", {});
// Recursively spawn grandchildren if subBreakdown exists
if (spawnPlan.subBreakdown && spawnPlan.subBreakdown.length > 0) {
for (const grandSpawn of spawnPlan.subBreakdown) {
for (let j = 0; j < grandSpawn.count; j++) {
const grandId = ulid();
const grandRecord: SubTaskRecord = {
id: grandId,
pipelineId: req.pipelineId,
parentId: childId,
role: grandSpawn.role,
agentName,
title: `${grandSpawn.role}-${j + 1}`,
description: grandSpawn.rationale,
complexityScore: null,
complexityTier: null,
model: ROLES[grandSpawn.role].primaryModel,
};
await rails.createSubTask(grandRecord);
await rails.recordEvent(grandId, "spawned", {
parent: childId,
role: grandSpawn.role,
});
await rails.recordEvent(grandId, "started", {});
// Third-level (junior) grand-grandchildren
if (grandSpawn.subBreakdown && grandSpawn.subBreakdown.length > 0) {
for (const ggSpawn of grandSpawn.subBreakdown) {
for (let k = 0; k < ggSpawn.count; k++) {
const ggId = ulid();
await rails.createSubTask({
id: ggId,
pipelineId: req.pipelineId,
parentId: grandId,
role: ggSpawn.role,
agentName,
title: `${ggSpawn.role}-${k + 1}`,
description: ggSpawn.rationale,
complexityScore: null,
complexityTier: null,
model: ROLES[ggSpawn.role].primaryModel,
});
await rails.recordEvent(ggId, "spawned", {
parent: grandId,
role: ggSpawn.role,
});
await rails.recordEvent(ggId, "started", {});
await simulateWork(ggSpawn.role);
await rails.recordEvent(ggId, "completed", { ok: true });
}
}
} else {
await simulateWork(grandSpawn.role);
}
await rails.recordEvent(grandId, "completed", { ok: true });
}
}
} else {
await simulateWork(spawnPlan.role);
}
await rails.recordEvent(childId, "completed", { ok: true });
}
}
void complexity; // reserved for future LLM-based planning
return buildSuccessResult(req.stage, req.task);
}
/**
* Placeholder "work" — tiny delay per role so timeline looks realistic.
* Replace with real openclaw agent CLI or LLM SDK call.
*/
async function simulateWork(role: Role): Promise<void> {
const delayByRole: Record<Role, number> = {
manager: 40,
principal: 60,
lead: 80,
junior: 100,
};
await new Promise((r) => setTimeout(r, delayByRole[role]));
}
function buildSuccessResult(
stage: InvokeRequest["stage"],
task: InvokeRequest["task"],
): HandoffMessage {
switch (stage) {
case "plan":
return {
stage: "plan",
verdict: "PLAN_READY",
payload: {
planDir: ".plans",
sprintId: "SPRINT-AUTO",
contractId: "",
},
abortReason: "",
};
case "implement":
return {
stage: "implement",
verdict: "IMPL_DONE",
payload: {
branch: "feature/sister-agent",
commits: ["simulated"],
workdir: task.workdir || "",
selfTestReport: { simulated: true },
},
errorReason: "",
};
case "review":
return {
stage: "review",
verdict: "APPROVE",
payload: {
artifactPath: "",
checklistResults: [],
issues: [],
},
abortReason: "",
};
case "deploy":
return {
stage: "deploy",
verdict: "DEPLOY_DONE",
payload: {
deployArtifactPath: "",
projectType: "simulated",
verificationResults: {},
},
errorReason: "",
};
}
}
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 };
}
}
function countPlanNodes(plan: DecompositionPlan): number {
const inner = (spawns: DecompositionPlan["spawn"]): number => {
let total = 0;
for (const s of spawns) {
total += s.count;
if (s.subBreakdown) total += s.count * inner(s.subBreakdown);
}
return total;
};
return 1 + inner(plan.spawn);
}

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

@@ -0,0 +1,89 @@
import { z } from "zod";
// ── Roles ──
export const Role = z.enum(["manager", "principal", "lead", "junior"]);
export type Role = z.infer<typeof Role>;
// ── 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(""),
}),
timeoutMs: z.number().int().positive().default(600_000),
railsApiUrl: z.string().url(),
agentName: 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"]
}

54
src/cli/abort.ts Normal file
View File

@@ -0,0 +1,54 @@
import { defineCommand } from "citty";
import { loadEnv } from "../env.js";
import {
getPipelineState,
sendEvent,
disconnectPrisma,
} from "../orchestrator/persist.js";
export default defineCommand({
meta: {
name: "abort",
description: "Abort a running or escalated pipeline",
},
args: {
pipelineId: {
type: "positional",
description: "Pipeline ID to abort",
required: true,
},
reason: {
type: "string",
alias: "r",
description: "Reason for abort",
default: "Manual abort via CLI",
},
},
async run({ args }) {
loadEnv();
try {
const current = await getPipelineState(args.pipelineId);
if (!current) {
console.error(`Pipeline not found: ${args.pipelineId}`);
process.exitCode = 1;
return;
}
if (current.state === "done" || current.state === "aborted") {
console.log(`Pipeline already in terminal state: ${current.state}`);
return;
}
const result = await sendEvent(args.pipelineId, {
type: "ABORT",
reason: args.reason ?? "Manual abort",
});
console.log(
`Aborted pipeline ${args.pipelineId}: ${current.state}${result.state}`,
);
} finally {
await disconnectPrisma();
}
},
});

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

@@ -17,6 +17,12 @@ const main = defineCommand({
import("./skill-trace.js").then((m) => m.default), import("./skill-trace.js").then((m) => m.default),
contract: () => import("./contract.js").then((m) => m.default), contract: () => import("./contract.js").then((m) => m.default),
run: () => import("./run.js").then((m) => m.default), 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,
},
});

50
src/cli/resume.ts Normal file
View File

@@ -0,0 +1,50 @@
import { defineCommand } from "citty";
import { loadEnv } from "../env.js";
import {
getPipelineState,
sendEvent,
disconnectPrisma,
} from "../orchestrator/persist.js";
export default defineCommand({
meta: {
name: "resume",
description: "Resume an escalated pipeline",
},
args: {
pipelineId: {
type: "positional",
description: "Pipeline ID to resume",
required: true,
},
},
async run({ args }) {
loadEnv();
try {
const current = await getPipelineState(args.pipelineId);
if (!current) {
console.error(`Pipeline not found: ${args.pipelineId}`);
process.exitCode = 1;
return;
}
if (current.state !== "escalated") {
console.error(
`Pipeline ${args.pipelineId} is in state '${current.state}', not 'escalated'. Cannot resume.`,
);
process.exitCode = 1;
return;
}
const result = await sendEvent(args.pipelineId, { type: "RESUME" });
console.log(
`Resumed pipeline ${args.pipelineId}: ${current.state}${result.state}`,
);
console.log(
` retryCount reset. Call 'rails run' or orchestrator loop to continue processing.`,
);
} finally {
await disconnectPrisma();
}
},
});

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,72 @@
import { defineCommand } from "citty"; import { defineCommand } from "citty";
import { loadEnv } from "../env.js"; import { loadEnv } from "../env.js";
import { getLogger } from "../logger.js"; import { getLogger } from "../logger.js";
import { startHttpServer } from "../server/http.js";
import { disconnectPrisma } from "../orchestrator/persist.js";
export default defineCommand({ export default defineCommand({
meta: { meta: {
name: "serve", 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 env = loadEnv();
const log = getLogger(); const log = getLogger();
const port = parseInt(args.port || String(env.RAILS_PORT), 10);
const { url, close } = await startHttpServer({
port,
host: args.host ?? "0.0.0.0",
...(args.config && { configPath: args.config }),
});
log.info( log.info(
{ port: env.RAILS_PORT, nodeEnv: env.NODE_ENV }, { url, nodeEnv: env.NODE_ENV },
"hanarang-rails starting", "hanarang-rails server ready",
); );
// TODO (Sprint 004): Discord bot initialization // Graceful shutdown
// TODO (Sprint 004): Gitea webhook HTTP server const shutdown = async (signal: string) => {
// For now, just keep the process alive log.info({ signal }, "Shutdown requested");
log.info("Orchestrator running. Press Ctrl+C to stop."); 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"));
// Keep alive
await new Promise<never>(() => { await new Promise<never>(() => {
// keep alive until signal /* block until signal */
}); });
}, },
}); });

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

@@ -0,0 +1,83 @@
import type { SisterTransport } from "./transport.js";
import { MockTransport } from "./mock-transport.js";
import { HttpTransport } from "./http-transport.js";
import type { RailsConfig } from "../config/schema.js";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "transport-builder" });
/**
* Build a stage → transport map from rails config + environment.
*
* Environment overrides (convenient for testing):
* RAILS_TRANSPORT_MODE=mock|http|auto (default auto — use config)
* RAILS_API_URL=http://10.10.10.169:18800 (used as rails callback URL for sub-tasks)
* RAILS_AGENT_{STAGE}_HOST=10.10.10.112 (override agent host)
* RAILS_AGENT_{STAGE}_PORT=18801 (override agent port)
*/
export function buildTransports(config: RailsConfig): Map<string, SisterTransport> {
const transports = new Map<string, SisterTransport>();
const mode = process.env["RAILS_TRANSPORT_MODE"] ?? "auto";
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];
// Explicit mode override
if (mode === "mock") {
transports.set(stage, sharedMock);
continue;
}
// Determine desired transport
const desired = mode === "http" ? "http" : agentConfig?.transport ?? "mock";
if (desired === "mock") {
transports.set(stage, sharedMock);
log.info({ stage, transport: "mock" }, "transport wired");
continue;
}
if (desired === "http") {
const hostEnv = `RAILS_AGENT_${stage.toUpperCase()}_HOST`;
const portEnv = `RAILS_AGENT_${stage.toUpperCase()}_PORT`;
const host = process.env[hostEnv];
const port = process.env[portEnv] ?? "18801";
if (!host) {
log.warn(
{ stage, missing: hostEnv },
"HTTP transport requested but host env missing — falling back to mock",
);
transports.set(stage, sharedMock);
continue;
}
const endpoint = `http://${host}:${port}`;
const agentName = agentConfig?.role ?? stage;
transports.set(
stage,
new HttpTransport({
agentName,
endpoint,
railsApiUrl,
timeoutMs: agentConfig?.timeoutMs ?? 600_000,
}),
);
log.info(
{ stage, transport: "http", endpoint, agentName },
"transport wired",
);
continue;
}
// Unknown — fall back to mock
transports.set(stage, sharedMock);
log.warn({ stage, desired }, "unknown transport, using mock");
}
return transports;
}

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
}
}

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
},
};

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

@@ -0,0 +1,166 @@
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,
},
});
}
}
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

@@ -1,5 +1,5 @@
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { createActor, type Snapshot } from "xstate"; import { createActor } from "xstate";
import { ulid } from "ulid"; import { ulid } from "ulid";
import { pipelineMachine } from "./machine.js"; import { pipelineMachine } from "./machine.js";
import { createInitialContext, type PipelineContext } from "./context.js"; import { createInitialContext, type PipelineContext } from "./context.js";
@@ -17,6 +17,13 @@ export function getPrisma(): PrismaClient {
return _prisma; 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( export async function createPipeline(
projectName: string, projectName: string,
requirements: string, requirements: string,
@@ -25,20 +32,27 @@ export async function createPipeline(
const pipelineId = ulid(); const pipelineId = ulid();
const ctx = createInitialContext(pipelineId, projectName, requirements); const ctx = createInitialContext(pipelineId, projectName, requirements);
const actor = createActor(pipelineMachine, { const actor = createActor(pipelineMachine, { input: ctx });
input: ctx,
});
actor.start(); 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(); const snapshot = actor.getSnapshot();
actor.stop(); actor.stop();
// Merge our pipelineId into the persisted context for recovery
const persistedWithId = mergeContextIntoSnapshot(
persistedSnapshot,
ctx,
);
await prisma.pipeline.create({ await prisma.pipeline.create({
data: { data: {
id: pipelineId, id: pipelineId,
projectName, projectName,
requirements, requirements,
currentState: String(snapshot.value), currentState: String(snapshot.value),
contextJson: JSON.stringify(ctx), contextJson: JSON.stringify(persistedWithId),
}, },
}); });
@@ -56,21 +70,22 @@ export async function sendEvent(
where: { id: pipelineId }, where: { id: pipelineId },
}); });
const ctx = JSON.parse(pipeline.contextJson) as PipelineContext;
const fromState = pipeline.currentState; 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, { const actor = createActor(pipelineMachine, {
snapshot: { snapshot: persistedSnapshot,
value: fromState, } as Parameters<typeof createActor>[1]);
context: ctx,
} as unknown as Snapshot<unknown>,
});
actor.start(); actor.start();
actor.send(event); actor.send(event);
const snapshot = actor.getSnapshot(); const snapshot = actor.getSnapshot();
const toState = String(snapshot.value); const toState = String(snapshot.value);
const newContext = snapshot.context as PipelineContext; const newContext = snapshot.context as PipelineContext;
const newPersistedSnapshot = actor.getPersistedSnapshot();
actor.stop(); actor.stop();
await prisma.$transaction([ await prisma.$transaction([
@@ -78,7 +93,7 @@ export async function sendEvent(
where: { id: pipelineId }, where: { id: pipelineId },
data: { data: {
currentState: toState, currentState: toState,
contextJson: JSON.stringify(newContext), contextJson: JSON.stringify(newPersistedSnapshot),
}, },
}), }),
prisma.stateTransition.create({ prisma.stateTransition.create({
@@ -131,13 +146,32 @@ export async function getPipelineState(
if (!pipeline) return null; 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 { return {
state: pipeline.currentState as PipelineState, state: pipeline.currentState as PipelineState,
context: JSON.parse(pipeline.contextJson) as PipelineContext, context,
transitions: pipeline.transitions, 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 listPipelines(opts?: { export async function listPipelines(opts?: {
state?: PipelineState; state?: PipelineState;
limit?: number; limit?: number;

View File

@@ -4,6 +4,8 @@ import type { PipelineEvent, PipelineState } from "./events.js";
import type { HandoffMessage, InvokeRequest } from "../handoff/message.js"; import type { HandoffMessage, InvokeRequest } from "../handoff/message.js";
import type { SisterTransport } from "../handoff/transport.js"; import type { SisterTransport } from "../handoff/transport.js";
import type { RailsConfig } from "../config/schema.js"; import type { RailsConfig } from "../config/schema.js";
import { withRetry } from "../resilience/retry.js";
import { recordEscalation, type EscalationNotifier } from "../resilience/escalate.js";
import { childLogger } from "../logger.js"; import { childLogger } from "../logger.js";
const log = childLogger({ module: "runner" }); const log = childLogger({ module: "runner" });
@@ -14,6 +16,8 @@ export interface RunOptions {
config: RailsConfig; config: RailsConfig;
transports: Map<string, SisterTransport>; transports: Map<string, SisterTransport>;
signal?: AbortSignal; signal?: AbortSignal;
maxRetries?: number;
notifier?: EscalationNotifier;
} }
export interface RunResult { export interface RunResult {
@@ -89,19 +93,52 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
structuredOutput: true, structuredOutput: true,
}; };
try { const retryResult = await withRetry(
const handoff = await transport.invoke(invokeReq, opts.signal); async () => transport.invoke(invokeReq, opts.signal),
const event = handoffToEvent(handoff); {
maxRetries: opts.maxRetries ?? 3,
...(opts.signal && { signal: opts.signal }),
},
);
if (retryResult.ok && retryResult.value) {
const event = handoffToEvent(retryResult.value);
result = await sendEvent(pipelineId, event); result = await sendEvent(pipelineId, event);
transitions += 1; transitions += 1;
} catch (err) { } else {
const reason = err instanceof Error ? err.message : String(err); const classification = retryResult.classification;
log.error({ stage, reason }, "Transport invoke failed"); const reason =
retryResult.error?.message ?? "Unknown invoke failure";
log.error(
{
stage,
attempts: retryResult.attempts,
category: classification?.reason,
reason,
},
"Transport invoke failed after retries",
);
if (classification && !classification.retryable) {
await recordEscalation(
{
pipelineId,
stage,
reason,
attempts: retryResult.attempts,
classification,
contextSnapshot: result.context as unknown as Record<string, unknown>,
},
opts.notifier,
);
}
result = await sendEvent(pipelineId, { result = await sendEvent(pipelineId, {
type: "ERROR", type: "ERROR",
actor: stage, actor: stage,
reason, reason,
retryable: true, retryable: classification?.retryable ?? false,
}); });
transitions += 1; transitions += 1;
} }

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 };
}

46
src/resilience/backoff.ts Normal file
View File

@@ -0,0 +1,46 @@
/**
* Exponential backoff with jitter.
*
* Returns a wait duration (ms) given the current retry count.
* Starts at `base`, doubles each retry, capped at `max`, with ±30% jitter.
*
* Example (base=1000, max=30000):
* retry 0: ~1s
* retry 1: ~2s
* retry 2: ~4s
* retry 3: ~8s
* retry 4: ~16s
* retry 5+: ~30s (cap)
*/
export function backoffMs(
retryCount: number,
opts: { base?: number; max?: number; jitter?: number } = {},
): number {
const base = opts.base ?? 1000;
const max = opts.max ?? 30_000;
const jitterPct = opts.jitter ?? 0.3;
const exp = Math.min(base * Math.pow(2, retryCount), max);
const jitterAmount = Math.random() * jitterPct * 2 * exp - jitterPct * exp;
return Math.max(0, Math.floor(exp + jitterAmount));
}
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolveFn, rejectFn) => {
if (signal?.aborted) {
rejectFn(new Error("Aborted"));
return;
}
const timer = setTimeout(resolveFn, ms);
if (signal) {
signal.addEventListener(
"abort",
() => {
clearTimeout(timer);
rejectFn(new Error("Aborted"));
},
{ once: true },
);
}
});
}

View File

@@ -0,0 +1,126 @@
import { ZodError } from "zod";
export type ErrorReason =
| "timeout"
| "rate_limit"
| "network"
| "transient"
| "config"
| "permission"
| "invariant"
| "user_input_needed";
export interface ErrorClassification {
retryable: boolean;
reason: ErrorReason;
message: string;
}
export class TimeoutError extends Error {
constructor(message: string) {
super(message);
this.name = "TimeoutError";
}
}
export class RateLimitError extends Error {
constructor(message: string) {
super(message);
this.name = "RateLimitError";
}
}
export class NetworkError extends Error {
constructor(message: string) {
super(message);
this.name = "NetworkError";
}
}
export class PermissionError extends Error {
constructor(message: string) {
super(message);
this.name = "PermissionError";
}
}
export class ConfigError extends Error {
constructor(message: string) {
super(message);
this.name = "ConfigError";
}
}
/**
* Classify an arbitrary error into { retryable, reason }.
* Non-retryable errors should escalate immediately; retrying won't help.
*/
export function classifyError(err: unknown): ErrorClassification {
if (err instanceof TimeoutError) {
return { retryable: true, reason: "timeout", message: err.message };
}
if (err instanceof RateLimitError) {
return { retryable: true, reason: "rate_limit", message: err.message };
}
if (err instanceof NetworkError) {
return { retryable: true, reason: "network", message: err.message };
}
if (err instanceof ZodError) {
return {
retryable: false,
reason: "invariant",
message: `Schema validation failed: ${err.issues.map((i) => i.message).join("; ")}`,
};
}
if (err instanceof PermissionError) {
return { retryable: false, reason: "permission", message: err.message };
}
if (err instanceof ConfigError) {
return { retryable: false, reason: "config", message: err.message };
}
// Heuristic detection by message string for errors from 3rd-party libs
if (err instanceof Error) {
const msg = err.message.toLowerCase();
if (msg.includes("timeout") || msg.includes("etimedout") || msg.includes("abort")) {
return { retryable: true, reason: "timeout", message: err.message };
}
if (
msg.includes("econnrefused") ||
msg.includes("enotfound") ||
msg.includes("econnreset") ||
msg.includes("network")
) {
return { retryable: true, reason: "network", message: err.message };
}
if (msg.includes("rate limit") || msg.includes("429")) {
return { retryable: true, reason: "rate_limit", message: err.message };
}
if (msg.includes("eacces") || msg.includes("permission denied")) {
return { retryable: false, reason: "permission", message: err.message };
}
// Default: treat unknown errors as transient retryable
return { retryable: true, reason: "transient", message: err.message };
}
return {
retryable: true,
reason: "transient",
message: String(err),
};
}
/**
* Guard against disallowed thinking tiers (e.g., `xhigh` is known to hang).
* Throws a ConfigError if the forbidden tier is requested.
*/
const FORBIDDEN_THINKING_TIERS = new Set(["xhigh", "XHIGH"]);
export function assertAllowedThinkingTier(tier: string | undefined): void {
if (!tier) return;
if (FORBIDDEN_THINKING_TIERS.has(tier)) {
throw new ConfigError(
`Thinking tier '${tier}' is forbidden — known to cause indefinite waits. Use 'high' or below.`,
);
}
}

137
src/resilience/escalate.ts Normal file
View File

@@ -0,0 +1,137 @@
import { ulid } from "ulid";
import { getPrisma } from "../orchestrator/persist.js";
import type { ErrorClassification } from "./classifier.js";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "escalate" });
export interface EscalationInput {
pipelineId: string;
reason: string;
stage: string;
attempts: number;
classification?: ErrorClassification;
contextSnapshot: Record<string, unknown>;
}
export interface EscalationNotifier {
notify(message: {
title: string;
body: string;
mentionUser?: boolean;
}): Promise<void>;
}
/**
* Record an escalation in the database and (optionally) notify via a
* configured notifier. Returns the created escalation id.
*/
export async function recordEscalation(
input: EscalationInput,
notifier?: EscalationNotifier,
): Promise<string> {
const id = ulid();
const prisma = getPrisma();
await prisma.escalation.create({
data: {
id,
pipelineId: input.pipelineId,
reason: input.reason.slice(0, 500),
errorCategory: input.classification?.reason ?? "unknown",
stage: input.stage,
attempts: input.attempts,
contextSnapshot: JSON.stringify(input.contextSnapshot),
},
});
log.warn(
{
escalationId: id,
pipelineId: input.pipelineId,
stage: input.stage,
reason: input.reason,
},
"Escalation recorded",
);
if (notifier) {
try {
await notifier.notify({
title: `🚨 Pipeline escalation — ${input.pipelineId.slice(0, 8)}`,
body: buildNotifyBody(input),
mentionUser: true,
});
} catch (err) {
log.error(
{ err: err instanceof Error ? err.message : String(err) },
"Escalation notifier failed (non-fatal)",
);
}
}
return id;
}
function buildNotifyBody(input: EscalationInput): string {
const cat = input.classification?.reason ?? "unknown";
const lines = [
`**Stage:** ${input.stage}`,
`**Attempts:** ${input.attempts}`,
`**Category:** ${cat}`,
`**Reason:** ${input.reason}`,
"",
"Actions:",
` \`rails resume ${input.pipelineId}\` — retry`,
` \`rails abort ${input.pipelineId}\` — cancel`,
` \`rails inspect ${input.pipelineId}\` — inspect`,
];
return lines.join("\n");
}
export async function listEscalations(opts?: {
pipelineId?: string;
limit?: number;
}): Promise<
Array<{
id: string;
pipelineId: string;
reason: string;
errorCategory: string;
stage: string;
attempts: number;
createdAt: Date;
resolvedAt: Date | null;
}>
> {
const prisma = getPrisma();
return prisma.escalation.findMany({
where: opts?.pipelineId ? { pipelineId: opts.pipelineId } : undefined,
orderBy: { createdAt: "desc" },
take: opts?.limit ?? 20,
select: {
id: true,
pipelineId: true,
reason: true,
errorCategory: true,
stage: true,
attempts: true,
createdAt: true,
resolvedAt: true,
},
});
}
export async function resolveEscalation(
escalationId: string,
resolution: "resumed" | "aborted" | "manual",
): Promise<void> {
const prisma = getPrisma();
await prisma.escalation.update({
where: { id: escalationId },
data: {
resolvedAt: new Date(),
resolution,
},
});
}

97
src/resilience/kill.ts Normal file
View File

@@ -0,0 +1,97 @@
import type { ChildProcess } from "node:child_process";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "kill" });
/**
* Kill a child process and ensure it is dead.
* - First SIGTERM, wait up to graceMs
* - Then SIGKILL
* - If spawned with detached, also kill the process group (-pid)
*/
export async function killChildProcess(
child: ChildProcess,
opts: { graceMs?: number; killGroup?: boolean } = {},
): Promise<void> {
const graceMs = opts.graceMs ?? 2000;
const killGroup = opts.killGroup ?? false;
if (child.killed || child.exitCode !== null) {
return;
}
const pid = child.pid;
if (!pid) return;
log.debug({ pid }, "Sending SIGTERM to child");
try {
if (killGroup) {
process.kill(-pid, "SIGTERM");
} else {
child.kill("SIGTERM");
}
} catch {
// already gone
return;
}
// Wait for graceful exit
const exited = await Promise.race([
new Promise<boolean>((resolveFn) => {
child.once("exit", () => resolveFn(true));
}),
new Promise<boolean>((resolveFn) =>
setTimeout(() => resolveFn(false), graceMs),
),
]);
if (exited) return;
log.warn({ pid }, "Grace period elapsed, sending SIGKILL");
try {
if (killGroup) {
process.kill(-pid, "SIGKILL");
} else {
child.kill("SIGKILL");
}
} catch {
// already gone
}
}
/**
* Global cleanup registry — kill all tracked children on process exit.
*/
const tracked = new Set<ChildProcess>();
let handlersInstalled = false;
export function trackChild(child: ChildProcess): void {
tracked.add(child);
child.once("exit", () => tracked.delete(child));
installHandlers();
}
function installHandlers(): void {
if (handlersInstalled) return;
handlersInstalled = true;
const cleanup = () => {
for (const child of tracked) {
try {
child.kill("SIGTERM");
} catch {
/* ignore */
}
}
};
process.on("exit", cleanup);
process.on("SIGINT", () => {
cleanup();
process.exit(130);
});
process.on("SIGTERM", () => {
cleanup();
process.exit(143);
});
}

108
src/resilience/retry.ts Normal file
View File

@@ -0,0 +1,108 @@
import { backoffMs, sleep } from "./backoff.js";
import { classifyError, type ErrorClassification } from "./classifier.js";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "retry" });
export interface RetryOptions {
maxRetries?: number;
baseMs?: number;
maxMs?: number;
onRetry?: (info: {
attempt: number;
classification: ErrorClassification;
delayMs: number;
}) => void;
signal?: AbortSignal;
}
export interface RetryResult<T> {
ok: boolean;
value?: T;
error?: Error;
classification?: ErrorClassification;
attempts: number;
}
/**
* Run `fn` with automatic retries for retryable errors.
* Non-retryable errors break out immediately (caller should escalate).
*
* Returns RetryResult — never throws.
*/
export async function withRetry<T>(
fn: (attempt: number) => Promise<T>,
opts: RetryOptions = {},
): Promise<RetryResult<T>> {
const maxRetries = opts.maxRetries ?? 3;
let lastErr: Error | undefined;
let lastClass: ErrorClassification | undefined;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (opts.signal?.aborted) {
return {
ok: false,
error: new Error("Aborted"),
attempts: attempt,
};
}
try {
const value = await fn(attempt);
return { ok: true, value, attempts: attempt + 1 };
} catch (err) {
const classification = classifyError(err);
lastErr = err instanceof Error ? err : new Error(String(err));
lastClass = classification;
log.warn(
{ attempt, reason: classification.reason, retryable: classification.retryable, message: classification.message },
"Attempt failed",
);
if (!classification.retryable) {
log.error({ attempt, reason: classification.reason }, "Non-retryable error — stop");
return {
ok: false,
error: lastErr,
classification,
attempts: attempt + 1,
};
}
if (attempt >= maxRetries) {
log.error({ attempts: attempt + 1, maxRetries }, "Max retries exceeded");
return {
ok: false,
error: lastErr,
classification,
attempts: attempt + 1,
};
}
const delayMs = backoffMs(attempt, {
base: opts.baseMs,
max: opts.maxMs,
});
opts.onRetry?.({ attempt: attempt + 1, classification, delayMs });
log.info({ attempt, delayMs }, "Backing off before retry");
try {
await sleep(delayMs, opts.signal);
} catch {
return {
ok: false,
error: new Error("Aborted during backoff"),
attempts: attempt + 1,
};
}
}
}
return {
ok: false,
error: lastErr ?? new Error("Unknown retry failure"),
classification: lastClass,
attempts: maxRetries + 1,
};
}

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

@@ -0,0 +1,253 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { z } from "zod";
import {
createPipeline,
getPipelineState,
listPipelines,
sendEvent,
} from "../orchestrator/persist.js";
import { runPipeline } from "../orchestrator/runner.js";
import { loadConfig } from "../config/loader.js";
import type { SisterTransport } from "../handoff/transport.js";
import { buildTransports } from "../handoff/build.js";
import {
CreateSubTaskInput,
SubTaskEventInput,
UpdateSubTaskInput,
createSubTask,
recordSubTaskEvent,
updateSubTask,
getSubTaskTree,
} 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),
});
const AbortRequest = z.object({
reason: z.string().default("aborted via api"),
});
interface ServerOpts {
port: number;
host?: string;
configPath?: 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 ──
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, mock: useMock } = parsed.data;
// Only mock mode is wired right now — real transports come in a follow-up.
if (!useMock) {
return sendJson(res, 501, {
error: "not_implemented",
message: "Non-mock transport wiring deferred to next iteration.",
});
}
// Run pipeline (async, but we await for this simple demo)
const result = await runPipeline({
projectName: project,
requirements,
config,
transports,
});
return sendJson(res, 201, {
pipelineId: result.pipelineId,
finalState: result.finalState,
transitions: result.transitions,
});
}
// ── 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 });
}
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));
}

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");
});
});

223
tests/resilience.test.ts Normal file
View File

@@ -0,0 +1,223 @@
import { describe, it, expect } from "vitest";
import { backoffMs, sleep } from "../src/resilience/backoff.js";
import {
classifyError,
assertAllowedThinkingTier,
TimeoutError,
NetworkError,
RateLimitError,
PermissionError,
ConfigError,
} from "../src/resilience/classifier.js";
import { withRetry } from "../src/resilience/retry.js";
import { ZodError, z } from "zod";
describe("backoffMs", () => {
it("starts near base for retry 0", () => {
const ms = backoffMs(0, { base: 1000, max: 30_000, jitter: 0 });
expect(ms).toBe(1000);
});
it("doubles each retry", () => {
expect(backoffMs(1, { base: 1000, max: 30_000, jitter: 0 })).toBe(2000);
expect(backoffMs(2, { base: 1000, max: 30_000, jitter: 0 })).toBe(4000);
expect(backoffMs(3, { base: 1000, max: 30_000, jitter: 0 })).toBe(8000);
});
it("caps at max", () => {
expect(backoffMs(10, { base: 1000, max: 30_000, jitter: 0 })).toBe(30_000);
expect(backoffMs(20, { base: 1000, max: 30_000, jitter: 0 })).toBe(30_000);
});
it("adds jitter within bounds", () => {
// With jitter 0.3, retry 0 should be in [700, 1300]
for (let i = 0; i < 50; i++) {
const ms = backoffMs(0, { base: 1000, max: 30_000, jitter: 0.3 });
expect(ms).toBeGreaterThanOrEqual(700);
expect(ms).toBeLessThanOrEqual(1300);
}
});
it("returns non-negative values", () => {
for (let i = 0; i < 20; i++) {
expect(backoffMs(i)).toBeGreaterThanOrEqual(0);
}
});
});
describe("sleep", () => {
it("waits approximately the specified time", async () => {
const start = Date.now();
await sleep(50);
const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThanOrEqual(40);
expect(elapsed).toBeLessThan(200);
});
it("aborts when signal fires", async () => {
const controller = new AbortController();
const promise = sleep(5000, controller.signal);
setTimeout(() => controller.abort(), 10);
await expect(promise).rejects.toThrow("Aborted");
});
});
describe("classifyError", () => {
it("TimeoutError → retryable timeout", () => {
const r = classifyError(new TimeoutError("timed out"));
expect(r.retryable).toBe(true);
expect(r.reason).toBe("timeout");
});
it("NetworkError → retryable network", () => {
const r = classifyError(new NetworkError("econnrefused"));
expect(r.retryable).toBe(true);
expect(r.reason).toBe("network");
});
it("RateLimitError → retryable rate_limit", () => {
const r = classifyError(new RateLimitError("429 too many"));
expect(r.retryable).toBe(true);
expect(r.reason).toBe("rate_limit");
});
it("PermissionError → non-retryable permission", () => {
const r = classifyError(new PermissionError("EACCES"));
expect(r.retryable).toBe(false);
expect(r.reason).toBe("permission");
});
it("ConfigError → non-retryable config", () => {
const r = classifyError(new ConfigError("bad config"));
expect(r.retryable).toBe(false);
expect(r.reason).toBe("config");
});
it("ZodError → non-retryable invariant", () => {
const schema = z.object({ x: z.number() });
let zodErr: unknown;
try {
schema.parse({ x: "not a number" });
} catch (e) {
zodErr = e;
}
expect(zodErr).toBeInstanceOf(ZodError);
const r = classifyError(zodErr);
expect(r.retryable).toBe(false);
expect(r.reason).toBe("invariant");
});
it("detects timeout by message heuristic", () => {
const r = classifyError(new Error("ETIMEDOUT on request"));
expect(r.retryable).toBe(true);
expect(r.reason).toBe("timeout");
});
it("detects network error by message", () => {
const r = classifyError(new Error("ECONNREFUSED"));
expect(r.retryable).toBe(true);
expect(r.reason).toBe("network");
});
it("detects rate limit by message", () => {
const r = classifyError(new Error("429 Rate limit exceeded"));
expect(r.retryable).toBe(true);
expect(r.reason).toBe("rate_limit");
});
it("unknown error defaults to retryable transient", () => {
const r = classifyError(new Error("something weird"));
expect(r.retryable).toBe(true);
expect(r.reason).toBe("transient");
});
});
describe("assertAllowedThinkingTier", () => {
it("allows high and below", () => {
expect(() => assertAllowedThinkingTier("high")).not.toThrow();
expect(() => assertAllowedThinkingTier("medium")).not.toThrow();
expect(() => assertAllowedThinkingTier("low")).not.toThrow();
});
it("allows undefined", () => {
expect(() => assertAllowedThinkingTier(undefined)).not.toThrow();
});
it("forbids xhigh", () => {
expect(() => assertAllowedThinkingTier("xhigh")).toThrow(/forbidden/);
expect(() => assertAllowedThinkingTier("XHIGH")).toThrow(/forbidden/);
});
});
describe("withRetry", () => {
it("succeeds on first attempt", async () => {
let attempts = 0;
const result = await withRetry(async () => {
attempts += 1;
return "ok";
});
expect(result.ok).toBe(true);
expect(result.value).toBe("ok");
expect(result.attempts).toBe(1);
expect(attempts).toBe(1);
});
it("retries retryable errors and eventually succeeds", async () => {
let attempts = 0;
const result = await withRetry(
async () => {
attempts += 1;
if (attempts < 3) throw new TimeoutError("not yet");
return "finally";
},
{ maxRetries: 3, baseMs: 1, maxMs: 10 },
);
expect(result.ok).toBe(true);
expect(result.value).toBe("finally");
expect(result.attempts).toBe(3);
});
it("stops on non-retryable error", async () => {
let attempts = 0;
const result = await withRetry(
async () => {
attempts += 1;
throw new PermissionError("no");
},
{ maxRetries: 3, baseMs: 1 },
);
expect(result.ok).toBe(false);
expect(result.classification?.retryable).toBe(false);
expect(attempts).toBe(1);
});
it("gives up after max retries", async () => {
let attempts = 0;
const result = await withRetry(
async () => {
attempts += 1;
throw new TimeoutError("never succeeds");
},
{ maxRetries: 2, baseMs: 1, maxMs: 10 },
);
expect(result.ok).toBe(false);
expect(result.attempts).toBe(3); // initial + 2 retries
expect(attempts).toBe(3);
});
it("aborts when signal fires mid-backoff", async () => {
const controller = new AbortController();
let attempts = 0;
const promise = withRetry(
async () => {
attempts += 1;
throw new TimeoutError("slow");
},
{ maxRetries: 5, baseMs: 1000, maxMs: 5000, signal: controller.signal },
);
setTimeout(() => controller.abort(), 50);
const result = await promise;
expect(result.ok).toBe(false);
expect(result.error?.message).toContain("Aborted");
});
});