Compare commits
30 Commits
feature/sp
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
| 98540af98c | |||
| 579137e4bf | |||
| 9aeef223c6 | |||
| e2d71ca47d | |||
| 3e354d21c1 | |||
| a88323716d | |||
| c8d6ceb337 | |||
| 1f518c0c54 | |||
| 3a226ada95 | |||
| 7a8f2c1af0 | |||
| f6c1768c60 | |||
| 8786efc81c | |||
| 2cadb3e0df | |||
| 1d37fee3ad | |||
| 256f334706 | |||
| da85b92a6b | |||
| 83fb627d2f | |||
| 39d5f26c40 | |||
| 30e782a32d | |||
| b630779909 | |||
| 38c579f177 | |||
| fcd2e56129 | |||
| eb63428174 | |||
| 8f0691aafb | |||
| 58d6c262d5 | |||
| 205084cc60 | |||
| 53d91c08d6 | |||
| ae86d95155 | |||
| 813c65077a | |||
| ac47b91bb5 |
344
.plans/design/hierarchy.md
Normal file
344
.plans/design/hierarchy.md
Normal 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 합산 로직
|
||||||
22
Plans.md
22
Plans.md
@@ -16,19 +16,23 @@
|
|||||||
| # | Sprint | 상세 | Status |
|
| # | Sprint | 상세 | Status |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 0 | 세이프티 네트 + 실패 감사 + 프로젝트 세팅 | [SPRINT-000](.plans/sprints/SPRINT-000-safety-and-audit.md) | cc:완료 [bac114d] |
|
| 0 | 세이프티 네트 + 실패 감사 + 프로젝트 세팅 | [SPRINT-000](.plans/sprints/SPRINT-000-safety-and-audit.md) | cc:완료 [bac114d] |
|
||||||
| 1 | 스켈레톤: XState FSM + orchestrator + .plans/ 스캐폴딩 | [SPRINT-001](.plans/sprints/SPRINT-001-skeleton.md) | cc:WIP |
|
| 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:TODO |
|
| 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:TODO |
|
| 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:TODO |
|
| 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 001 — 스켈레톤: XState FSM + orchestrator + CLI** (`cc:WIP`)
|
**전체 완료** — 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 실행
|
||||||
|
|
||||||
## 마커 범례
|
## 마커 범례
|
||||||
|
|
||||||
|
|||||||
52
README.md
52
README.md
@@ -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) — F1–F6 실패 감사
|
||||||
- [`.plans/design/`](.plans/design/) — 설계 문서
|
- [`.plans/design/`](.plans/design/) — 설계 문서 9종
|
||||||
- [`.plans/sprints/`](.plans/sprints/) — 스프린트 상세
|
- [`.plans/sprints/`](.plans/sprints/) — 스프린트 상세
|
||||||
|
|
||||||
## 라이선스
|
## 라이선스
|
||||||
|
|||||||
198
docs/discord-setup.md
Normal file
198
docs/discord-setup.md
Normal 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
180
docs/migration-guide.md
Normal 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 (F1–F6)
|
||||||
|
- [`.plans/design/`](../.plans/design/) — architecture docs
|
||||||
|
- `docs/operations.md` — day-to-day operations guide
|
||||||
185
docs/operations.md
Normal file
185
docs/operations.md
Normal 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` — F1–F6 that rails prevents
|
||||||
@@ -1,8 +1,40 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# hanarang-rails post-tool hook (thin shim)
|
# hanarang-rails post-tool hook
|
||||||
# 현재 no-op — Sprint 002 에서 skill bypass 감지 + revert 로직 주입 예정.
|
# Appends tool usage to skill trace for audit.
|
||||||
# 입력: stdin 으로 tool use result JSON
|
# Input: stdin JSON event from Claude Code
|
||||||
# 출력: exit 0 = proceed
|
# Exit: always 0 (post-hook should not block)
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
EVENT=$(cat)
|
||||||
|
CWD="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||||
|
TRACE_FILE="$CWD/.rails/skill-trace.jsonl"
|
||||||
|
|
||||||
|
# Ensure directory
|
||||||
|
mkdir -p "$(dirname "$TRACE_FILE")"
|
||||||
|
|
||||||
|
# Extract fields
|
||||||
|
TOOL=$(echo "$EVENT" | jq -r '.tool_name // "unknown"' 2>/dev/null || echo "unknown")
|
||||||
|
SESSION_ID="${CLAUDE_SESSION_ID:-}"
|
||||||
|
|
||||||
|
# Read pipeline ID from context if available
|
||||||
|
PIPELINE_ID=""
|
||||||
|
CONTEXT_FILE="$CWD/.rails/skill-context.json"
|
||||||
|
if [[ -f "$CONTEXT_FILE" ]]; then
|
||||||
|
PIPELINE_ID=$(jq -r '.pipelineId // ""' "$CONTEXT_FILE" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Append trace entry
|
||||||
|
ENTRY=$(jq -n \
|
||||||
|
--argjson ts "$(date +%s)000" \
|
||||||
|
--arg tool "$TOOL" \
|
||||||
|
--arg cwd "$CWD" \
|
||||||
|
--arg sessionId "$SESSION_ID" \
|
||||||
|
--arg pipelineId "$PIPELINE_ID" \
|
||||||
|
'{ts: $ts, tool: $tool, cwd: $cwd, sessionId: $sessionId, pipelineId: $pipelineId, blocked: false, reason: "post-trace"}' \
|
||||||
|
2>/dev/null || true)
|
||||||
|
|
||||||
|
if [[ -n "$ENTRY" ]]; then
|
||||||
|
echo "$ENTRY" >> "$TRACE_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -1,8 +1,50 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# hanarang-rails pre-tool hook (thin shim)
|
# hanarang-rails pre-tool hook
|
||||||
# 현재 no-op — Sprint 002 에서 skill-enforcement 로직 주입 예정.
|
# Blocks Write/Edit/Bash if no valid skill context exists.
|
||||||
# 입력: stdin 으로 tool use event JSON
|
# Input: stdin JSON event from Claude Code
|
||||||
# 출력: exit 0 = proceed, exit 2 = block
|
# Exit: 0 = allow, 2 = block
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Escape hatch
|
||||||
|
if [[ "${RAILS_ENFORCE:-on}" == "off" ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Read tool event from stdin
|
||||||
|
EVENT=$(cat)
|
||||||
|
TOOL=$(echo "$EVENT" | jq -r '.tool_name // empty' 2>/dev/null || true)
|
||||||
|
|
||||||
|
# Only gate Write, Edit, Bash
|
||||||
|
case "$TOOL" in
|
||||||
|
Write|Edit|Bash) ;;
|
||||||
|
*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Find project root
|
||||||
|
CWD="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||||
|
CONTEXT_FILE="$CWD/.rails/skill-context.json"
|
||||||
|
|
||||||
|
# Check context exists
|
||||||
|
if [[ ! -f "$CONTEXT_FILE" ]]; then
|
||||||
|
echo "[rails-enforce] No skill context. Enter the pipeline via /rails first." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check context not expired (TTL check)
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
CREATED=$(jq -r '.createdAt // empty' "$CONTEXT_FILE" 2>/dev/null || true)
|
||||||
|
TTL=$(jq -r '.ttlSeconds // 300' "$CONTEXT_FILE" 2>/dev/null || echo 300)
|
||||||
|
|
||||||
|
if [[ -n "$CREATED" ]]; then
|
||||||
|
CREATED_EPOCH=$(date -d "$CREATED" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%S" "${CREATED%%.*}" +%s 2>/dev/null || echo 0)
|
||||||
|
NOW_EPOCH=$(date +%s)
|
||||||
|
AGE=$(( NOW_EPOCH - CREATED_EPOCH ))
|
||||||
|
|
||||||
|
if [[ "$AGE" -gt "$TTL" ]]; then
|
||||||
|
echo "[rails-enforce] Skill context expired (age: ${AGE}s > ttl: ${TTL}s). Re-enter the skill." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
50
qa-templates/bugfix-v1.yaml
Normal file
50
qa-templates/bugfix-v1.yaml
Normal 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
|
||||||
66
qa-templates/feature-v1.yaml
Normal file
66
qa-templates/feature-v1.yaml
Normal 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
|
||||||
46
qa-templates/infra-v1.yaml
Normal file
46
qa-templates/infra-v1.yaml
Normal 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
|
||||||
53
qa-templates/migration-v1.yaml
Normal file
53
qa-templates/migration-v1.yaml
Normal 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
|
||||||
50
qa-templates/refactor-v1.yaml
Normal file
50
qa-templates/refactor-v1.yaml
Normal 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
|
||||||
54
qa-templates/scaffold-v1.yaml
Normal file
54
qa-templates/scaffold-v1.yaml
Normal 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
|
||||||
44
rails.config.example.yaml
Normal file
44
rails.config.example.yaml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# hanarang-rails sample configuration.
|
||||||
|
# Copy to rails.config.yaml and tune for your environment.
|
||||||
|
|
||||||
|
pipeline:
|
||||||
|
stages:
|
||||||
|
- plan
|
||||||
|
- implement
|
||||||
|
- review
|
||||||
|
- deploy
|
||||||
|
|
||||||
|
agents:
|
||||||
|
plan:
|
||||||
|
role: plan
|
||||||
|
displayName: Planner
|
||||||
|
transport: mock # or discord, local
|
||||||
|
channelId: "" # discord channel id for this agent
|
||||||
|
timeoutMs: 30000
|
||||||
|
|
||||||
|
implement:
|
||||||
|
role: implement
|
||||||
|
displayName: Generator
|
||||||
|
transport: mock
|
||||||
|
channelId: ""
|
||||||
|
timeoutMs: 60000
|
||||||
|
|
||||||
|
review:
|
||||||
|
role: review
|
||||||
|
displayName: Evaluator
|
||||||
|
transport: mock
|
||||||
|
channelId: ""
|
||||||
|
timeoutMs: 30000
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
role: deploy
|
||||||
|
displayName: Deploy
|
||||||
|
transport: mock
|
||||||
|
channelId: ""
|
||||||
|
timeoutMs: 30000
|
||||||
|
|
||||||
|
discord:
|
||||||
|
enabled: false
|
||||||
|
railsToken: ${RAILS_DISCORD_TOKEN}
|
||||||
|
guildId: ${DISCORD_GUILD_ID}
|
||||||
|
pipelineChannelId: ${DISCORD_PIPELINE_CHANNEL_ID}
|
||||||
1
sister-agent/.claude/sessions/.last_inbox_check
Normal file
1
sister-agent/.claude/sessions/.last_inbox_check
Normal file
@@ -0,0 +1 @@
|
|||||||
|
1775814166
|
||||||
0
sister-agent/.claude/state/session-events.lock
Normal file
0
sister-agent/.claude/state/session-events.lock
Normal file
0
sister-agent/.claude/state/session.events.jsonl
Normal file
0
sister-agent/.claude/state/session.events.jsonl
Normal file
0
sister-agent/.claude/state/session.json
Normal file
0
sister-agent/.claude/state/session.json
Normal file
7
sister-agent/.claude/state/test-recommendation.json
Normal file
7
sister-agent/.claude/state/test-recommendation.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"timestamp": "2026-04-10T09:46:21Z",
|
||||||
|
"changed_file": "/home/erang/hanarang-rails/src/orchestrator/runner.ts",
|
||||||
|
"test_command": "npm test",
|
||||||
|
"related_test": "",
|
||||||
|
"recommendation": "テストの実行を推奨します"
|
||||||
|
}
|
||||||
1
sister-agent/.claude/state/tool-failure-counter.txt
Normal file
1
sister-agent/.claude/state/tool-failure-counter.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
1 1775808904
|
||||||
26
sister-agent/package.json
Normal file
26
sister-agent/package.json
Normal 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
1029
sister-agent/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
173
sister-agent/src/complexity.ts
Normal file
173
sister-agent/src/complexity.ts
Normal 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";
|
||||||
|
}
|
||||||
1
sister-agent/src/index.ts
Normal file
1
sister-agent/src/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "./server.js";
|
||||||
108
sister-agent/src/llm.ts
Normal file
108
sister-agent/src/llm.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
export interface LlmResult {
|
||||||
|
ok: boolean;
|
||||||
|
text: string;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPENCLAW_BIN =
|
||||||
|
process.env["OPENCLAW_BIN"] ??
|
||||||
|
`${process.env["HOME"]}/.npm-global/bin/openclaw`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call openclaw infer model run via subprocess.
|
||||||
|
* Returns the structured JSON the CLI emits with --json.
|
||||||
|
*
|
||||||
|
* Note: openclaw enforces an allowlist per agent. We pass through to the
|
||||||
|
* default model unless an explicit override is requested AND it's allowed.
|
||||||
|
*/
|
||||||
|
export async function callLlm(opts: {
|
||||||
|
prompt: string;
|
||||||
|
modelOverride?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): Promise<LlmResult> {
|
||||||
|
const args = ["infer", "model", "run", "--prompt", opts.prompt, "--json"];
|
||||||
|
if (opts.modelOverride) {
|
||||||
|
args.push("--model", opts.modelOverride);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolveFn) => {
|
||||||
|
const child = spawn(OPENCLAW_BIN, args, {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
let settled = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
resolveFn({
|
||||||
|
ok: false,
|
||||||
|
text: "",
|
||||||
|
provider: "",
|
||||||
|
model: opts.modelOverride ?? "default",
|
||||||
|
errorMessage: `LLM timeout after ${opts.timeoutMs ?? 120_000}ms`,
|
||||||
|
});
|
||||||
|
}, opts.timeoutMs ?? 120_000);
|
||||||
|
|
||||||
|
child.stdout.on("data", (b: Buffer) => (stdout += b.toString()));
|
||||||
|
child.stderr.on("data", (b: Buffer) => (stderr += b.toString()));
|
||||||
|
child.on("error", (err) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolveFn({
|
||||||
|
ok: false,
|
||||||
|
text: "",
|
||||||
|
provider: "",
|
||||||
|
model: opts.modelOverride ?? "default",
|
||||||
|
errorMessage: `LLM spawn error: ${err.message}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
resolveFn({
|
||||||
|
ok: false,
|
||||||
|
text: "",
|
||||||
|
provider: "",
|
||||||
|
model: opts.modelOverride ?? "default",
|
||||||
|
errorMessage: `openclaw exit ${code}: ${stderr.slice(0, 500)}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stdout) as {
|
||||||
|
ok: boolean;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
outputs: Array<{ text: string }>;
|
||||||
|
};
|
||||||
|
const text = parsed.outputs?.[0]?.text ?? "";
|
||||||
|
resolveFn({
|
||||||
|
ok: parsed.ok,
|
||||||
|
text,
|
||||||
|
provider: parsed.provider,
|
||||||
|
model: parsed.model,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
resolveFn({
|
||||||
|
ok: false,
|
||||||
|
text: "",
|
||||||
|
provider: "",
|
||||||
|
model: opts.modelOverride ?? "default",
|
||||||
|
errorMessage: `JSON parse failed: ${err instanceof Error ? err.message : String(err)}\nstdout: ${stdout.slice(0, 500)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
191
sister-agent/src/planner.ts
Normal file
191
sister-agent/src/planner.ts
Normal 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;
|
||||||
|
}
|
||||||
103
sister-agent/src/prompts.ts
Normal file
103
sister-agent/src/prompts.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import type { Role } from "./types.js";
|
||||||
|
|
||||||
|
export interface PromptContext {
|
||||||
|
role: Role;
|
||||||
|
agentName: string; // harang/narang/darang/erang
|
||||||
|
stage: "plan" | "implement" | "review" | "deploy";
|
||||||
|
taskTitle: string;
|
||||||
|
taskDescription: string;
|
||||||
|
prevStageOutput?: string;
|
||||||
|
parentTitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGE_KOREAN: Record<string, string> = {
|
||||||
|
plan: "기획",
|
||||||
|
implement: "구현",
|
||||||
|
review: "검토",
|
||||||
|
deploy: "배포",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROLE_KOREAN: Record<Role, string> = {
|
||||||
|
manager: "부장",
|
||||||
|
principal: "수석",
|
||||||
|
lead: "선임",
|
||||||
|
junior: "신입",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROLE_RESPONSIBILITY: Record<Role, string> = {
|
||||||
|
manager:
|
||||||
|
"팀 전체의 전략을 결정하고 최종 결과물의 품질을 책임진다. 본인이 직접 코드를 짜지 않고 아래 팀에 분배한다.",
|
||||||
|
principal:
|
||||||
|
"기술적 분해와 리뷰를 담당한다. 부장의 방향을 받아 구체적인 실행 단위로 쪼갠다.",
|
||||||
|
lead:
|
||||||
|
"실행 리드. 작은 팀을 조율하면서 신입의 작업물을 검증하고 합친다.",
|
||||||
|
junior:
|
||||||
|
"한 가지 명확한 작업을 직접 실행한다. 결과물(텍스트, 코드, 답변)을 명확하게 제출한다.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the prompt the LLM will see for this node.
|
||||||
|
* The pattern: short system context + concrete task + previous output (if any).
|
||||||
|
*
|
||||||
|
* Output format hint: ask for plain text. Keeping it simple — no JSON parsing
|
||||||
|
* required from the LLM (we already have structure from the spawn tree).
|
||||||
|
*/
|
||||||
|
export function buildPrompt(ctx: PromptContext): string {
|
||||||
|
const stageKor = STAGE_KOREAN[ctx.stage] ?? ctx.stage;
|
||||||
|
const roleKor = ROLE_KOREAN[ctx.role];
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`# 역할`);
|
||||||
|
lines.push(
|
||||||
|
`너는 "${ctx.agentName}" 자매의 ${roleKor}(${ctx.role})이다. ${ROLE_RESPONSIBILITY[ctx.role]}`,
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`# 현재 단계`);
|
||||||
|
lines.push(`${stageKor} (stage=${ctx.stage})`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`# 작업`);
|
||||||
|
lines.push(`제목: ${ctx.taskTitle}`);
|
||||||
|
if (ctx.taskDescription) {
|
||||||
|
lines.push(`상세: ${ctx.taskDescription}`);
|
||||||
|
}
|
||||||
|
if (ctx.parentTitle) {
|
||||||
|
lines.push(`상위 작업: ${ctx.parentTitle}`);
|
||||||
|
}
|
||||||
|
if (ctx.prevStageOutput) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`# 이전 단계 결과 (참고)`);
|
||||||
|
lines.push(ctx.prevStageOutput.slice(0, 4000));
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`# 출력 형식`);
|
||||||
|
lines.push(roleOutputHint(ctx.role, ctx.stage));
|
||||||
|
lines.push(`반드시 한국어로 답해. 200-400자 내외로 핵심만.`);
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleOutputHint(role: Role, stage: PromptContext["stage"]): string {
|
||||||
|
if (role === "manager") {
|
||||||
|
return `이 작업을 어떻게 분해할지, 어떤 팀(수석/선임/신입)을 어디에 배치할지 한 문단으로 결정해.`;
|
||||||
|
}
|
||||||
|
if (role === "principal") {
|
||||||
|
return `${STAGE_KOREAN[stage]} 단계에서 구체적으로 어떤 리스크가 있고, 어떻게 분해되어야 하는지 bullet 으로 제시해.`;
|
||||||
|
}
|
||||||
|
if (role === "lead") {
|
||||||
|
return `이 작업을 신입에게 어떻게 나눠줄지, 검증 포인트는 무엇인지 bullet 으로 정리해.`;
|
||||||
|
}
|
||||||
|
// junior
|
||||||
|
if (stage === "plan") {
|
||||||
|
return `이 프로젝트의 핵심 plan 을 마크다운 bullet 형식으로 작성해.`;
|
||||||
|
}
|
||||||
|
if (stage === "implement") {
|
||||||
|
return `요구된 코드/파일/내용을 그대로 작성해. 코드면 코드 블록으로.`;
|
||||||
|
}
|
||||||
|
if (stage === "review") {
|
||||||
|
return `위 결과물을 평가하고 APPROVE 또는 REQUEST_CHANGES 로 시작해서 이유를 한 문단.`;
|
||||||
|
}
|
||||||
|
if (stage === "deploy") {
|
||||||
|
return `이 결과물을 어떻게 배포 검증할지 짧게 설명하고 마지막 줄에 "DEPLOY_DONE" 또는 "DEPLOY_FAILED" 표기.`;
|
||||||
|
}
|
||||||
|
return `결과를 명확히 제출해.`;
|
||||||
|
}
|
||||||
60
sister-agent/src/rails-client.ts
Normal file
60
sister-agent/src/rails-client.ts
Normal 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
42
sister-agent/src/roles.ts
Normal 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: "신입",
|
||||||
|
};
|
||||||
119
sister-agent/src/server.ts
Normal file
119
sister-agent/src/server.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force agentName to this sister's identity (env), not whatever rails sent.
|
||||||
|
// The stage info is preserved separately in parsed.data.stage.
|
||||||
|
const req2 = { ...parsed.data, agentName: AGENT_NAME };
|
||||||
|
const railsClient = new RailsClient(req2.railsApiUrl);
|
||||||
|
|
||||||
|
log("info", "invoke.start", {
|
||||||
|
pipelineId: req2.pipelineId,
|
||||||
|
stage: req2.stage,
|
||||||
|
});
|
||||||
|
|
||||||
|
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"));
|
||||||
402
sister-agent/src/spawn.ts
Normal file
402
sister-agent/src/spawn.ts
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
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";
|
||||||
|
import { callLlm } from "./llm.js";
|
||||||
|
import { buildPrompt } from "./prompts.js";
|
||||||
|
|
||||||
|
const USE_REAL_LLM = process.env["RAILS_USE_REAL_LLM"] !== "false";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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";
|
||||||
|
|
||||||
|
// Manager executes its own decision/judgment first
|
||||||
|
const managerWork = await doWork({
|
||||||
|
role,
|
||||||
|
agentName,
|
||||||
|
stage: req.stage,
|
||||||
|
taskTitle: req.task.title,
|
||||||
|
taskDescription: req.task.description,
|
||||||
|
});
|
||||||
|
await persistResult(rails, selfId, managerWork);
|
||||||
|
|
||||||
|
if (!hasChildren) {
|
||||||
|
return buildSuccessResult(req.stage, req.task, managerWork.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate texts from all children to feed back into the final result
|
||||||
|
const childTexts: string[] = [managerWork.text];
|
||||||
|
|
||||||
|
// Spawn children per plan
|
||||||
|
for (const spawnPlan of plan.spawn) {
|
||||||
|
for (let i = 0; i < spawnPlan.count; i++) {
|
||||||
|
const childId = ulid();
|
||||||
|
const childTitle = `${spawnPlan.role}-${i + 1}: ${req.task.title.slice(0, 100)}`;
|
||||||
|
await rails.createSubTask({
|
||||||
|
id: childId,
|
||||||
|
pipelineId: req.pipelineId,
|
||||||
|
parentId: selfId,
|
||||||
|
role: spawnPlan.role,
|
||||||
|
agentName,
|
||||||
|
title: childTitle,
|
||||||
|
description: spawnPlan.rationale,
|
||||||
|
complexityScore: null,
|
||||||
|
complexityTier: null,
|
||||||
|
model: ROLES[spawnPlan.role].primaryModel,
|
||||||
|
});
|
||||||
|
await rails.recordEvent(childId, "spawned", { parent: selfId, role: spawnPlan.role });
|
||||||
|
await rails.recordEvent(childId, "started", {});
|
||||||
|
|
||||||
|
let childWorkText = "";
|
||||||
|
|
||||||
|
if (spawnPlan.subBreakdown && spawnPlan.subBreakdown.length > 0) {
|
||||||
|
// Principal-level: do its own assessment then spawn leads
|
||||||
|
const principalWork = await doWork({
|
||||||
|
role: spawnPlan.role,
|
||||||
|
agentName,
|
||||||
|
stage: req.stage,
|
||||||
|
taskTitle: childTitle,
|
||||||
|
taskDescription: spawnPlan.rationale,
|
||||||
|
parentTitle: req.task.title,
|
||||||
|
prevStageOutput: managerWork.text,
|
||||||
|
});
|
||||||
|
await persistResult(rails, childId, principalWork);
|
||||||
|
childWorkText = principalWork.text;
|
||||||
|
|
||||||
|
for (const grandSpawn of spawnPlan.subBreakdown) {
|
||||||
|
for (let j = 0; j < grandSpawn.count; j++) {
|
||||||
|
const grandId = ulid();
|
||||||
|
const grandTitle = `${grandSpawn.role}-${j + 1}`;
|
||||||
|
await rails.createSubTask({
|
||||||
|
id: grandId,
|
||||||
|
pipelineId: req.pipelineId,
|
||||||
|
parentId: childId,
|
||||||
|
role: grandSpawn.role,
|
||||||
|
agentName,
|
||||||
|
title: grandTitle,
|
||||||
|
description: grandSpawn.rationale,
|
||||||
|
complexityScore: null,
|
||||||
|
complexityTier: null,
|
||||||
|
model: ROLES[grandSpawn.role].primaryModel,
|
||||||
|
});
|
||||||
|
await rails.recordEvent(grandId, "spawned", { parent: childId, role: grandSpawn.role });
|
||||||
|
await rails.recordEvent(grandId, "started", {});
|
||||||
|
|
||||||
|
if (grandSpawn.subBreakdown && grandSpawn.subBreakdown.length > 0) {
|
||||||
|
// Lead does its own work then spawns juniors
|
||||||
|
const leadWork = await doWork({
|
||||||
|
role: grandSpawn.role,
|
||||||
|
agentName,
|
||||||
|
stage: req.stage,
|
||||||
|
taskTitle: grandTitle,
|
||||||
|
taskDescription: grandSpawn.rationale,
|
||||||
|
parentTitle: childTitle,
|
||||||
|
prevStageOutput: principalWork.text,
|
||||||
|
});
|
||||||
|
await persistResult(rails, grandId, leadWork);
|
||||||
|
|
||||||
|
for (const ggSpawn of grandSpawn.subBreakdown) {
|
||||||
|
for (let k = 0; k < ggSpawn.count; k++) {
|
||||||
|
const ggId = ulid();
|
||||||
|
const ggTitle = `${ggSpawn.role}-${k + 1}`;
|
||||||
|
await rails.createSubTask({
|
||||||
|
id: ggId,
|
||||||
|
pipelineId: req.pipelineId,
|
||||||
|
parentId: grandId,
|
||||||
|
role: ggSpawn.role,
|
||||||
|
agentName,
|
||||||
|
title: ggTitle,
|
||||||
|
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", {});
|
||||||
|
|
||||||
|
const juniorWork = await doWork({
|
||||||
|
role: ggSpawn.role,
|
||||||
|
agentName,
|
||||||
|
stage: req.stage,
|
||||||
|
taskTitle: ggTitle,
|
||||||
|
taskDescription: ggSpawn.rationale,
|
||||||
|
parentTitle: grandTitle,
|
||||||
|
prevStageOutput: leadWork.text,
|
||||||
|
});
|
||||||
|
await persistResult(rails, ggId, juniorWork);
|
||||||
|
await rails.recordEvent(ggId, "completed", { ok: juniorWork.ok });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// grandSpawn is a leaf (junior or lead acting alone)
|
||||||
|
const leafWork = await doWork({
|
||||||
|
role: grandSpawn.role,
|
||||||
|
agentName,
|
||||||
|
stage: req.stage,
|
||||||
|
taskTitle: grandTitle,
|
||||||
|
taskDescription: grandSpawn.rationale,
|
||||||
|
parentTitle: childTitle,
|
||||||
|
prevStageOutput: principalWork.text,
|
||||||
|
});
|
||||||
|
await persistResult(rails, grandId, leafWork);
|
||||||
|
}
|
||||||
|
await rails.recordEvent(grandId, "completed", { ok: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Direct child is leaf — execute work and store
|
||||||
|
const leafWork = await doWork({
|
||||||
|
role: spawnPlan.role,
|
||||||
|
agentName,
|
||||||
|
stage: req.stage,
|
||||||
|
taskTitle: childTitle,
|
||||||
|
taskDescription: spawnPlan.rationale,
|
||||||
|
parentTitle: req.task.title,
|
||||||
|
prevStageOutput: managerWork.text,
|
||||||
|
});
|
||||||
|
await persistResult(rails, childId, leafWork);
|
||||||
|
childWorkText = leafWork.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
childTexts.push(childWorkText);
|
||||||
|
await rails.recordEvent(childId, "completed", { ok: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void complexity;
|
||||||
|
return buildSuccessResult(
|
||||||
|
req.stage,
|
||||||
|
req.task,
|
||||||
|
childTexts.filter(Boolean).join("\n\n---\n\n").slice(0, 6000),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistResult(
|
||||||
|
rails: RailsClient,
|
||||||
|
subTaskId: string,
|
||||||
|
work: { ok: boolean; text: string; error?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await rails.patchSubTask(subTaskId, {
|
||||||
|
resultJson: JSON.stringify({ text: work.text, ok: work.ok }),
|
||||||
|
...(work.error && { errorReason: work.error }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run actual work for a node. Calls openclaw infer model run if RAILS_USE_REAL_LLM
|
||||||
|
* is enabled (default). Falls back to short sleep if disabled.
|
||||||
|
*
|
||||||
|
* Returns the LLM text output (or empty if simulation).
|
||||||
|
*/
|
||||||
|
async function doWork(args: {
|
||||||
|
role: Role;
|
||||||
|
agentName: string;
|
||||||
|
stage: InvokeRequest["stage"];
|
||||||
|
taskTitle: string;
|
||||||
|
taskDescription: string;
|
||||||
|
prevStageOutput?: string;
|
||||||
|
parentTitle?: string;
|
||||||
|
}): Promise<{ ok: boolean; text: string; error?: string }> {
|
||||||
|
if (!USE_REAL_LLM) {
|
||||||
|
await new Promise((r) => setTimeout(r, 80));
|
||||||
|
return { ok: true, text: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = buildPrompt({
|
||||||
|
role: args.role,
|
||||||
|
agentName: args.agentName,
|
||||||
|
stage: args.stage,
|
||||||
|
taskTitle: args.taskTitle,
|
||||||
|
taskDescription: args.taskDescription,
|
||||||
|
...(args.prevStageOutput !== undefined && { prevStageOutput: args.prevStageOutput }),
|
||||||
|
...(args.parentTitle !== undefined && { parentTitle: args.parentTitle }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callLlm({
|
||||||
|
prompt,
|
||||||
|
timeoutMs: 90_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return { ok: false, text: "", error: result.errorMessage ?? "unknown LLM error" };
|
||||||
|
}
|
||||||
|
return { ok: true, text: result.text };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSuccessResult(
|
||||||
|
stage: InvokeRequest["stage"],
|
||||||
|
task: InvokeRequest["task"],
|
||||||
|
outputText?: string,
|
||||||
|
): HandoffMessage {
|
||||||
|
// Pack the LLM output into selfTestReport / verificationResults so the
|
||||||
|
// next stage can read it via the handoff payload.
|
||||||
|
void outputText;
|
||||||
|
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
89
sister-agent/src/types.ts
Normal 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;
|
||||||
|
}
|
||||||
23
sister-agent/tsconfig.json
Normal file
23
sister-agent/tsconfig.json
Normal 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
54
src/cli/abort.ts
Normal 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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
153
src/cli/contract.ts
Normal file
153
src/cli/contract.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { defineCommand } from "citty";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { generateDraftContract } from "../contract/generator.js";
|
||||||
|
import {
|
||||||
|
saveDraftContract,
|
||||||
|
loadContract,
|
||||||
|
freezeContract,
|
||||||
|
contractFilePath,
|
||||||
|
} from "../contract/store.js";
|
||||||
|
import { validateContract } from "../contract/validator.js";
|
||||||
|
import { disconnectPrisma } from "../orchestrator/persist.js";
|
||||||
|
|
||||||
|
const generateCmd = defineCommand({
|
||||||
|
meta: { name: "generate", description: "Generate draft contract from sprint markdown" },
|
||||||
|
args: {
|
||||||
|
sprintMd: {
|
||||||
|
type: "positional",
|
||||||
|
description: "Path to sprint markdown file",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
sprintId: {
|
||||||
|
type: "string",
|
||||||
|
alias: "s",
|
||||||
|
description: "Sprint ID",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async run({ args }) {
|
||||||
|
try {
|
||||||
|
const draft = await generateDraftContract(args.sprintMd, args.sprintId);
|
||||||
|
const filePath = await saveDraftContract(process.cwd(), draft);
|
||||||
|
console.log(`Draft contract created:`);
|
||||||
|
console.log(` id: ${draft.id}`);
|
||||||
|
console.log(` sprintId: ${draft.sprintId}`);
|
||||||
|
console.log(` type: ${draft.type}`);
|
||||||
|
console.log(` checks: ${draft.dod.checks.length}`);
|
||||||
|
console.log(` path: ${filePath}`);
|
||||||
|
console.log(
|
||||||
|
`\nEdit the file to tune checks, then run: rails contract freeze ${draft.id}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await disconnectPrisma();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const freezeCmd = defineCommand({
|
||||||
|
meta: { name: "freeze", description: "Freeze a contract (make immutable)" },
|
||||||
|
args: {
|
||||||
|
contractId: {
|
||||||
|
type: "positional",
|
||||||
|
description: "Contract ID",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async run({ args }) {
|
||||||
|
try {
|
||||||
|
await freezeContract(process.cwd(), args.contractId);
|
||||||
|
console.log(`Contract ${args.contractId} frozen.`);
|
||||||
|
} finally {
|
||||||
|
await disconnectPrisma();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const validateCmd = defineCommand({
|
||||||
|
meta: { name: "validate", description: "Validate a contract against current state" },
|
||||||
|
args: {
|
||||||
|
contractId: {
|
||||||
|
type: "positional",
|
||||||
|
description: "Contract ID",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
workdir: {
|
||||||
|
type: "string",
|
||||||
|
alias: "w",
|
||||||
|
description: "Working directory",
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async run({ args }) {
|
||||||
|
try {
|
||||||
|
const contract = await loadContract(process.cwd(), args.contractId);
|
||||||
|
const result = await validateContract(contract, {
|
||||||
|
workdir: args.workdir || process.cwd(),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Contract: ${contract.id} (${contract.sprintId})`);
|
||||||
|
console.log(`Verdict: ${result.verdict}`);
|
||||||
|
console.log(
|
||||||
|
`Summary: ${result.summary.passed}/${result.summary.total} passed, ${result.summary.blockingFailed} blocking failures`,
|
||||||
|
);
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
if (result.verdict === "ABORT_PRECHECK") {
|
||||||
|
console.log("Environment prerequisites:");
|
||||||
|
for (const p of result.prerequisiteResults) {
|
||||||
|
console.log(` ${p.passed ? "✓" : "✗"} ${p.name}: ${p.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.checkResults.length > 0) {
|
||||||
|
console.log("DoD checks:");
|
||||||
|
for (const c of result.checkResults) {
|
||||||
|
const mark = c.passed ? "✓" : "✗";
|
||||||
|
const line = c.passed ? c.evidence : c.errorMessage;
|
||||||
|
console.log(` ${mark} [${c.severity}] ${c.id}: ${line}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.runtimeCommandResults.length > 0) {
|
||||||
|
console.log("Runtime commands:");
|
||||||
|
for (const r of result.runtimeCommandResults) {
|
||||||
|
const mark = r.passed ? "✓" : "✗";
|
||||||
|
console.log(` ${mark} ${r.name} (exit ${r.exitCode}, ${r.durationMs}ms)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exitCode = result.verdict === "PASS" ? 0 : 1;
|
||||||
|
} finally {
|
||||||
|
await disconnectPrisma();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const showCmd = defineCommand({
|
||||||
|
meta: { name: "show", description: "Pretty-print a contract" },
|
||||||
|
args: {
|
||||||
|
contractId: {
|
||||||
|
type: "positional",
|
||||||
|
description: "Contract ID",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async run({ args }) {
|
||||||
|
const filePath = contractFilePath(process.cwd(), args.contractId);
|
||||||
|
const raw = await readFile(filePath, "utf8");
|
||||||
|
console.log(raw);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineCommand({
|
||||||
|
meta: {
|
||||||
|
name: "contract",
|
||||||
|
description: "Manage sprint contracts",
|
||||||
|
},
|
||||||
|
subCommands: {
|
||||||
|
generate: generateCmd,
|
||||||
|
freeze: freezeCmd,
|
||||||
|
validate: validateCmd,
|
||||||
|
show: showCmd,
|
||||||
|
},
|
||||||
|
});
|
||||||
160
src/cli/doctor.ts
Normal file
160
src/cli/doctor.ts
Normal 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
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -11,6 +11,18 @@ const main = defineCommand({
|
|||||||
start: () => import("./start.js").then((m) => m.default),
|
start: () => import("./start.js").then((m) => m.default),
|
||||||
status: () => import("./status.js").then((m) => m.default),
|
status: () => import("./status.js").then((m) => m.default),
|
||||||
serve: () => import("./serve.js").then((m) => m.default),
|
serve: () => import("./serve.js").then((m) => m.default),
|
||||||
|
"skill-context": () =>
|
||||||
|
import("./skill-context.js").then((m) => m.default),
|
||||||
|
"skill-trace": () =>
|
||||||
|
import("./skill-trace.js").then((m) => m.default),
|
||||||
|
contract: () => import("./contract.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
203
src/cli/migrate.ts
Normal 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
107
src/cli/qa.ts
Normal 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
50
src/cli/resume.ts
Normal 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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
85
src/cli/run.ts
Normal file
85
src/cli/run.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { defineCommand } from "citty";
|
||||||
|
import { loadEnv } from "../env.js";
|
||||||
|
import { loadConfig } from "../config/loader.js";
|
||||||
|
import { runPipeline } from "../orchestrator/runner.js";
|
||||||
|
import { MockTransport } from "../handoff/mock-transport.js";
|
||||||
|
import type { SisterTransport } from "../handoff/transport.js";
|
||||||
|
import { disconnectPrisma } from "../orchestrator/persist.js";
|
||||||
|
|
||||||
|
export default defineCommand({
|
||||||
|
meta: {
|
||||||
|
name: "run",
|
||||||
|
description: "Run a pipeline end-to-end through configured transports",
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
project: {
|
||||||
|
type: "positional",
|
||||||
|
description: "Project name",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
requirements: {
|
||||||
|
type: "string",
|
||||||
|
alias: "r",
|
||||||
|
description: "Task description",
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
type: "string",
|
||||||
|
alias: "c",
|
||||||
|
description: "Path to rails.config.yaml",
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
mock: {
|
||||||
|
type: "boolean",
|
||||||
|
description: "Force mock transport for all stages",
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async run({ args }) {
|
||||||
|
loadEnv();
|
||||||
|
try {
|
||||||
|
const config = await loadConfig(args.config || undefined);
|
||||||
|
const transports = new Map<string, SisterTransport>();
|
||||||
|
|
||||||
|
if (args.mock) {
|
||||||
|
const mock = new MockTransport();
|
||||||
|
for (const stage of config.pipeline.stages) {
|
||||||
|
transports.set(stage, mock);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For now, default to mock when no real transport wiring is provided.
|
||||||
|
// Sprint 004 ships DiscordTransport as a class; wiring a live discord
|
||||||
|
// client is an operator task (see docs/discord-setup.md).
|
||||||
|
const mock = new MockTransport();
|
||||||
|
for (const stage of config.pipeline.stages) {
|
||||||
|
const t = config.agents[stage]?.transport;
|
||||||
|
if (t === "mock" || !t) {
|
||||||
|
transports.set(stage, mock);
|
||||||
|
} else if (t === "discord") {
|
||||||
|
console.warn(
|
||||||
|
`[rails] Discord transport for stage '${stage}' requires a bot wiring — falling back to mock.`,
|
||||||
|
);
|
||||||
|
transports.set(stage, mock);
|
||||||
|
} else {
|
||||||
|
transports.set(stage, mock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runPipeline({
|
||||||
|
projectName: args.project,
|
||||||
|
requirements: args.requirements ?? "",
|
||||||
|
config,
|
||||||
|
transports,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Pipeline: ${result.pipelineId}`);
|
||||||
|
console.log(`Final state: ${result.finalState}`);
|
||||||
|
console.log(`Transitions: ${result.transitions}`);
|
||||||
|
|
||||||
|
process.exitCode = result.finalState === "done" ? 0 : 1;
|
||||||
|
} finally {
|
||||||
|
await disconnectPrisma();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
127
src/cli/scaffold.ts
Normal file
127
src/cli/scaffold.ts
Normal 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");
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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/cli/skill-context.ts
Normal file
83
src/cli/skill-context.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { defineCommand } from "citty";
|
||||||
|
import {
|
||||||
|
createSkillContext,
|
||||||
|
readSkillContext,
|
||||||
|
clearSkillContext,
|
||||||
|
contextAgeSeconds,
|
||||||
|
isContextExpired,
|
||||||
|
} from "../enforcement/skill-context.js";
|
||||||
|
|
||||||
|
export default defineCommand({
|
||||||
|
meta: {
|
||||||
|
name: "skill-context",
|
||||||
|
description: "Manage skill enforcement context",
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
action: {
|
||||||
|
type: "positional",
|
||||||
|
description: "Action: create | show | clear",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
skillName: {
|
||||||
|
type: "string",
|
||||||
|
alias: "s",
|
||||||
|
description: "Skill name (for create)",
|
||||||
|
default: "rails",
|
||||||
|
},
|
||||||
|
pipelineId: {
|
||||||
|
type: "string",
|
||||||
|
alias: "p",
|
||||||
|
description: "Pipeline ID (for create)",
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
ttl: {
|
||||||
|
type: "string",
|
||||||
|
description: "TTL in seconds (for create)",
|
||||||
|
default: "300",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async run({ args }) {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
|
||||||
|
switch (args.action) {
|
||||||
|
case "create": {
|
||||||
|
const ctx = await createSkillContext(cwd, {
|
||||||
|
skillName: args.skillName,
|
||||||
|
pipelineId: args.pipelineId,
|
||||||
|
ttlSeconds: parseInt(args.ttl, 10) || 300,
|
||||||
|
});
|
||||||
|
console.log(`Skill context created:`);
|
||||||
|
console.log(` skill: ${ctx.skillName}`);
|
||||||
|
console.log(` pipeline: ${ctx.pipelineId || "(none)"}`);
|
||||||
|
console.log(` ttl: ${ctx.ttlSeconds}s`);
|
||||||
|
console.log(` created: ${ctx.createdAt}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "show": {
|
||||||
|
const ctx = await readSkillContext(cwd);
|
||||||
|
if (!ctx) {
|
||||||
|
console.log("No skill context found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const age = contextAgeSeconds(ctx);
|
||||||
|
const expired = isContextExpired(ctx);
|
||||||
|
console.log(`Skill context:`);
|
||||||
|
console.log(` skill: ${ctx.skillName}`);
|
||||||
|
console.log(` pipeline: ${ctx.pipelineId || "(none)"}`);
|
||||||
|
console.log(` session: ${ctx.sessionId || "(none)"}`);
|
||||||
|
console.log(` created: ${ctx.createdAt}`);
|
||||||
|
console.log(` age: ${age}s / ${ctx.ttlSeconds}s`);
|
||||||
|
console.log(` expired: ${expired}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "clear": {
|
||||||
|
const cleared = await clearSkillContext(cwd);
|
||||||
|
console.log(cleared ? "Skill context cleared." : "No context to clear.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
console.error(`Unknown action: ${args.action}. Use create | show | clear.`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
68
src/cli/skill-trace.ts
Normal file
68
src/cli/skill-trace.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { defineCommand } from "citty";
|
||||||
|
import { readTrace, countBlocked } from "../enforcement/skill-trace.js";
|
||||||
|
|
||||||
|
export default defineCommand({
|
||||||
|
meta: {
|
||||||
|
name: "skill-trace",
|
||||||
|
description: "View skill enforcement trace log",
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
action: {
|
||||||
|
type: "positional",
|
||||||
|
description: "Action: show | blocked",
|
||||||
|
required: false,
|
||||||
|
default: "show",
|
||||||
|
},
|
||||||
|
pipelineId: {
|
||||||
|
type: "string",
|
||||||
|
alias: "p",
|
||||||
|
description: "Filter by pipeline ID",
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: "string",
|
||||||
|
alias: "n",
|
||||||
|
description: "Number of entries to show",
|
||||||
|
default: "20",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async run({ args }) {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const action = args.action || "show";
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case "show": {
|
||||||
|
const entries = await readTrace(cwd, {
|
||||||
|
pipelineId: args.pipelineId || undefined,
|
||||||
|
limit: parseInt(args.limit, 10) || 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
console.log("No trace entries found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${"TIMESTAMP".padEnd(15)} ${"TOOL".padEnd(10)} ${"BLOCKED".padEnd(8)} REASON`,
|
||||||
|
);
|
||||||
|
console.log("-".repeat(60));
|
||||||
|
for (const e of entries) {
|
||||||
|
const time = new Date(e.ts).toISOString().slice(11, 19);
|
||||||
|
console.log(
|
||||||
|
`${time.padEnd(15)} ${e.tool.padEnd(10)} ${String(e.blocked).padEnd(8)} ${e.reason}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`\nTotal: ${entries.length} entries`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "blocked": {
|
||||||
|
const count = await countBlocked(cwd);
|
||||||
|
console.log(`Blocked tool calls: ${count}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
console.error(`Unknown action: ${action}. Use show | blocked.`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
57
src/config/loader.ts
Normal file
57
src/config/loader.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { parse as parseYaml } from "yaml";
|
||||||
|
import { RailsConfig, DEFAULT_CONFIG } from "./schema.js";
|
||||||
|
import type { RailsConfig as Config } from "./schema.js";
|
||||||
|
import { childLogger } from "../logger.js";
|
||||||
|
|
||||||
|
const log = childLogger({ module: "config-loader" });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve ${VAR_NAME} patterns in string values against process.env.
|
||||||
|
* Returns the original string if no variable reference.
|
||||||
|
*/
|
||||||
|
function interpolate(value: unknown, env: Record<string, string>): unknown {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (_, name: string) => {
|
||||||
|
return env[name] ?? "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((v) => interpolate(v, env));
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(value)) {
|
||||||
|
result[k] = interpolate(v, env);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadConfig(
|
||||||
|
configPath?: string,
|
||||||
|
env: Record<string, string> = process.env as Record<string, string>,
|
||||||
|
): Promise<Config> {
|
||||||
|
if (!configPath) {
|
||||||
|
log.info("No config file specified, using defaults");
|
||||||
|
return DEFAULT_CONFIG;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = resolve(configPath);
|
||||||
|
try {
|
||||||
|
const raw = await readFile(fullPath, "utf8");
|
||||||
|
const parsed = parseYaml(raw) as unknown;
|
||||||
|
const interpolated = interpolate(parsed, env);
|
||||||
|
const config = RailsConfig.parse(interpolated);
|
||||||
|
log.info({ path: fullPath }, "Config loaded");
|
||||||
|
return config;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||||
|
log.warn({ path: fullPath }, "Config file not found, using defaults");
|
||||||
|
return DEFAULT_CONFIG;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/config/schema.ts
Normal file
46
src/config/schema.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const TransportMode = z.enum(["discord", "mock", "local"]);
|
||||||
|
export type TransportMode = z.infer<typeof TransportMode>;
|
||||||
|
|
||||||
|
export const AgentConfig = z.object({
|
||||||
|
role: z.string().min(1),
|
||||||
|
displayName: z.string().default(""),
|
||||||
|
transport: TransportMode.default("mock"),
|
||||||
|
channelId: z.string().default(""),
|
||||||
|
timeoutMs: z.number().int().positive().default(30_000),
|
||||||
|
});
|
||||||
|
export type AgentConfig = z.infer<typeof AgentConfig>;
|
||||||
|
|
||||||
|
export const DiscordConfig = z.object({
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
|
railsToken: z.string().default(""),
|
||||||
|
guildId: z.string().default(""),
|
||||||
|
pipelineChannelId: z.string().default(""),
|
||||||
|
});
|
||||||
|
export type DiscordConfig = z.infer<typeof DiscordConfig>;
|
||||||
|
|
||||||
|
export const PipelineConfig = z.object({
|
||||||
|
stages: z
|
||||||
|
.array(z.enum(["plan", "implement", "review", "deploy"]))
|
||||||
|
.default(["plan", "implement", "review", "deploy"]),
|
||||||
|
});
|
||||||
|
export type PipelineConfig = z.infer<typeof PipelineConfig>;
|
||||||
|
|
||||||
|
export const RailsConfig = z.object({
|
||||||
|
pipeline: PipelineConfig.default({}),
|
||||||
|
agents: z.record(z.string(), AgentConfig).default({}),
|
||||||
|
discord: DiscordConfig.default({}),
|
||||||
|
});
|
||||||
|
export type RailsConfig = z.infer<typeof RailsConfig>;
|
||||||
|
|
||||||
|
export const DEFAULT_CONFIG: RailsConfig = RailsConfig.parse({
|
||||||
|
pipeline: { stages: ["plan", "implement", "review", "deploy"] },
|
||||||
|
agents: {
|
||||||
|
plan: { role: "plan", displayName: "Planner", transport: "mock" },
|
||||||
|
implement: { role: "implement", displayName: "Generator", transport: "mock" },
|
||||||
|
review: { role: "review", displayName: "Evaluator", transport: "mock" },
|
||||||
|
deploy: { role: "deploy", displayName: "Deploy", transport: "mock" },
|
||||||
|
},
|
||||||
|
discord: { enabled: false },
|
||||||
|
});
|
||||||
62
src/contract/checks/artifact-schema.ts
Normal file
62
src/contract/checks/artifact-schema.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { resolve, isAbsolute } from "node:path";
|
||||||
|
import { ArtifactSchemaSpec, CheckResult } from "../schema.js";
|
||||||
|
import type { CheckHandler } from "./types.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Registry of known artifact schemas. Extend as needed.
|
||||||
|
const ARTIFACT_SCHEMAS: Record<string, z.ZodTypeAny> = {
|
||||||
|
CheckResult: CheckResult,
|
||||||
|
// Add more schemas here
|
||||||
|
};
|
||||||
|
|
||||||
|
export const artifactSchemaCheck: CheckHandler = async (check, ctx) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const spec = ArtifactSchemaSpec.parse(check.spec);
|
||||||
|
|
||||||
|
const fullPath = isAbsolute(spec.artifactPath)
|
||||||
|
? spec.artifactPath
|
||||||
|
: resolve(ctx.workdir, spec.artifactPath);
|
||||||
|
|
||||||
|
const schema = ARTIFACT_SCHEMAS[spec.schemaName];
|
||||||
|
if (!schema) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
evidence: "",
|
||||||
|
errorMessage: `Unknown schema: ${spec.schemaName}. Known: ${Object.keys(ARTIFACT_SCHEMAS).join(", ")}`,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await readFile(fullPath, "utf8");
|
||||||
|
const data = JSON.parse(content) as unknown;
|
||||||
|
const result = schema.safeParse(data);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return {
|
||||||
|
passed: true,
|
||||||
|
evidence: `${spec.artifactPath} validates against ${spec.schemaName}`,
|
||||||
|
errorMessage: "",
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const issues = result.error.issues
|
||||||
|
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||||
|
.join("; ");
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
evidence: "",
|
||||||
|
errorMessage: `Schema validation failed: ${issues}`,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
evidence: "",
|
||||||
|
errorMessage: `Cannot parse artifact: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
86
src/contract/checks/command-success.ts
Normal file
86
src/contract/checks/command-success.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { CommandSuccessSpec } from "../schema.js";
|
||||||
|
import type { CheckHandler } from "./types.js";
|
||||||
|
|
||||||
|
interface ExecResult {
|
||||||
|
exitCode: number;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
timedOut: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function execCommand(
|
||||||
|
command: string,
|
||||||
|
opts: {
|
||||||
|
cwd: string;
|
||||||
|
env: Record<string, string>;
|
||||||
|
timeoutMs: number;
|
||||||
|
},
|
||||||
|
): Promise<ExecResult> {
|
||||||
|
return new Promise((resolvePromise) => {
|
||||||
|
const child = spawn("sh", ["-c", command], {
|
||||||
|
cwd: opts.cwd,
|
||||||
|
env: opts.env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
let timedOut = false;
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const finalize = (exitCode: number) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolvePromise({ exitCode, stdout, stderr, timedOut });
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!child.killed) child.kill("SIGKILL");
|
||||||
|
finalize(-1);
|
||||||
|
}, 2000);
|
||||||
|
}, opts.timeoutMs);
|
||||||
|
|
||||||
|
child.stdout?.on("data", (chunk: Buffer) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stderr?.on("data", (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.on("exit", (code) => finalize(code ?? -1));
|
||||||
|
child.on("error", () => finalize(-1));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const commandSuccessCheck: CheckHandler = async (check, ctx) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const spec = CommandSuccessSpec.parse(check.spec);
|
||||||
|
|
||||||
|
const result = await execCommand(spec.command, {
|
||||||
|
cwd: spec.cwd ?? ctx.workdir,
|
||||||
|
env: { ...ctx.env, ...(spec.env ?? {}) },
|
||||||
|
timeoutMs: spec.timeoutMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
const passed = !result.timedOut && result.exitCode === spec.expectExitCode;
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed,
|
||||||
|
evidence: passed
|
||||||
|
? `Command succeeded: ${spec.command} (exit ${result.exitCode})`
|
||||||
|
: "",
|
||||||
|
errorMessage: passed
|
||||||
|
? ""
|
||||||
|
: result.timedOut
|
||||||
|
? `Command timed out after ${spec.timeoutMs}ms: ${spec.command}`
|
||||||
|
: `Command failed (exit ${result.exitCode}, expected ${spec.expectExitCode}): ${spec.command}\nstderr: ${result.stderr.slice(0, 500)}`,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export helper for runtime validation commands
|
||||||
|
export { execCommand };
|
||||||
47
src/contract/checks/db-query.ts
Normal file
47
src/contract/checks/db-query.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { getPrisma } from "../../orchestrator/persist.js";
|
||||||
|
import { DbQuerySpec } from "../schema.js";
|
||||||
|
import type { CheckHandler } from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a raw SQL query via Prisma and counts rows.
|
||||||
|
* For MariaDB / MySQL via the project's default DATABASE_URL.
|
||||||
|
* (Custom connection strings via spec.connectionString are deferred to v2.)
|
||||||
|
*/
|
||||||
|
export const dbQueryCheck: CheckHandler = async (check) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const spec = DbQuerySpec.parse(check.spec);
|
||||||
|
|
||||||
|
if (spec.connectionString) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
evidence: "",
|
||||||
|
errorMessage:
|
||||||
|
"Custom connectionString not supported yet. Omit to use DATABASE_URL.",
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
const rows = (await prisma.$queryRawUnsafe(spec.query)) as unknown[];
|
||||||
|
const count = Array.isArray(rows) ? rows.length : 0;
|
||||||
|
const passed = count >= spec.expectMinRows;
|
||||||
|
return {
|
||||||
|
passed,
|
||||||
|
evidence: passed
|
||||||
|
? `Query returned ${count} rows (expected ≥ ${spec.expectMinRows})`
|
||||||
|
: "",
|
||||||
|
errorMessage: passed
|
||||||
|
? ""
|
||||||
|
: `Query returned ${count} rows, expected ≥ ${spec.expectMinRows}`,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
evidence: "",
|
||||||
|
errorMessage: `DB query failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
29
src/contract/checks/file-exists.ts
Normal file
29
src/contract/checks/file-exists.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { stat } from "node:fs/promises";
|
||||||
|
import { resolve, isAbsolute } from "node:path";
|
||||||
|
import { FileExistsSpec } from "../schema.js";
|
||||||
|
import type { CheckHandler } from "./types.js";
|
||||||
|
|
||||||
|
export const fileExistsCheck: CheckHandler = async (check, ctx) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const spec = FileExistsSpec.parse(check.spec);
|
||||||
|
const fullPath = isAbsolute(spec.path)
|
||||||
|
? spec.path
|
||||||
|
: resolve(ctx.workdir, spec.path);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const s = await stat(fullPath);
|
||||||
|
return {
|
||||||
|
passed: true,
|
||||||
|
evidence: `${fullPath} exists (${s.isDirectory() ? "dir" : "file"}, ${s.size}B)`,
|
||||||
|
errorMessage: "",
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
evidence: "",
|
||||||
|
errorMessage: `File not found: ${fullPath}`,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
38
src/contract/checks/http-status.ts
Normal file
38
src/contract/checks/http-status.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { HttpStatusSpec } from "../schema.js";
|
||||||
|
import type { CheckHandler } from "./types.js";
|
||||||
|
|
||||||
|
export const httpStatusCheck: CheckHandler = async (check) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const spec = HttpStatusSpec.parse(check.spec);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), spec.timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(spec.url, {
|
||||||
|
method: spec.method,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
const passed = res.status === spec.expectStatus;
|
||||||
|
return {
|
||||||
|
passed,
|
||||||
|
evidence: passed
|
||||||
|
? `GET ${spec.url} → ${res.status} (expected ${spec.expectStatus})`
|
||||||
|
: "",
|
||||||
|
errorMessage: passed
|
||||||
|
? ""
|
||||||
|
: `HTTP status mismatch: ${spec.url} returned ${res.status}, expected ${spec.expectStatus}`,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
evidence: "",
|
||||||
|
errorMessage: `HTTP request failed: ${spec.url} — ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
25
src/contract/checks/index.ts
Normal file
25
src/contract/checks/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { DodCheckKind } from "../schema.js";
|
||||||
|
import type { CheckHandler } from "./types.js";
|
||||||
|
import { fileExistsCheck } from "./file-exists.js";
|
||||||
|
import { commandSuccessCheck } from "./command-success.js";
|
||||||
|
import { regexInFileCheck, regexAbsentCheck } from "./regex-in-file.js";
|
||||||
|
import { httpStatusCheck } from "./http-status.js";
|
||||||
|
import { processListeningCheck } from "./process-listening.js";
|
||||||
|
import { artifactSchemaCheck } from "./artifact-schema.js";
|
||||||
|
import { dbQueryCheck } from "./db-query.js";
|
||||||
|
import { manualCheck } from "./manual.js";
|
||||||
|
|
||||||
|
export const CHECK_HANDLERS: Record<DodCheckKind, CheckHandler> = {
|
||||||
|
file_exists: fileExistsCheck,
|
||||||
|
command_success: commandSuccessCheck,
|
||||||
|
regex_in_file: regexInFileCheck,
|
||||||
|
regex_absent: regexAbsentCheck,
|
||||||
|
http_status: httpStatusCheck,
|
||||||
|
db_query: dbQueryCheck,
|
||||||
|
process_listening: processListeningCheck,
|
||||||
|
artifact_schema: artifactSchemaCheck,
|
||||||
|
manual: manualCheck,
|
||||||
|
};
|
||||||
|
|
||||||
|
export { execCommand } from "./command-success.js";
|
||||||
|
export type { CheckHandler, CheckContext, CheckOutcome } from "./types.js";
|
||||||
19
src/contract/checks/manual.ts
Normal file
19
src/contract/checks/manual.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { ManualCheckSpec } from "../schema.js";
|
||||||
|
import type { CheckHandler } from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual check placeholder (Sprint 003).
|
||||||
|
* Will be activated in Sprint 006 (QA runtime) where darang LLM actually
|
||||||
|
* inspects code and fills in results. For now, returns SKIP (passed=true
|
||||||
|
* with a note) so validator can proceed.
|
||||||
|
*/
|
||||||
|
export const manualCheck: CheckHandler = async (check) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const spec = ManualCheckSpec.parse(check.spec);
|
||||||
|
return {
|
||||||
|
passed: true,
|
||||||
|
evidence: `[SKIPPED — manual] ${spec.question} (Sprint 006 에서 활성화)`,
|
||||||
|
errorMessage: "",
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
};
|
||||||
45
src/contract/checks/process-listening.ts
Normal file
45
src/contract/checks/process-listening.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { createConnection } from "node:net";
|
||||||
|
import { ProcessListeningSpec } from "../schema.js";
|
||||||
|
import type { CheckHandler } from "./types.js";
|
||||||
|
|
||||||
|
function probePort(
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return new Promise((resolvePromise) => {
|
||||||
|
const socket = createConnection({ host, port });
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const finalize = (ok: boolean) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
socket.destroy();
|
||||||
|
resolvePromise(ok);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => finalize(false), timeoutMs);
|
||||||
|
|
||||||
|
socket.on("connect", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
finalize(true);
|
||||||
|
});
|
||||||
|
socket.on("error", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
finalize(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const processListeningCheck: CheckHandler = async (check) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const spec = ProcessListeningSpec.parse(check.spec);
|
||||||
|
|
||||||
|
const ok = await probePort(spec.host, spec.port, 3000);
|
||||||
|
return {
|
||||||
|
passed: ok,
|
||||||
|
evidence: ok ? `${spec.host}:${spec.port} is listening` : "",
|
||||||
|
errorMessage: ok ? "" : `${spec.host}:${spec.port} is not listening`,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
};
|
||||||
76
src/contract/checks/regex-in-file.ts
Normal file
76
src/contract/checks/regex-in-file.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { resolve, isAbsolute } from "node:path";
|
||||||
|
import { RegexInFileSpec } from "../schema.js";
|
||||||
|
import type { CheckHandler } from "./types.js";
|
||||||
|
|
||||||
|
export const regexInFileCheck: CheckHandler = async (check, ctx) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const spec = RegexInFileSpec.parse(check.spec);
|
||||||
|
const fullPath = isAbsolute(spec.path)
|
||||||
|
? spec.path
|
||||||
|
: resolve(ctx.workdir, spec.path);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await readFile(fullPath, "utf8");
|
||||||
|
const regex = new RegExp(spec.pattern, spec.flags);
|
||||||
|
const match = content.match(regex);
|
||||||
|
if (match) {
|
||||||
|
const lineIdx = content.slice(0, match.index ?? 0).split("\n").length;
|
||||||
|
return {
|
||||||
|
passed: true,
|
||||||
|
evidence: `${spec.path}:${lineIdx} matches /${spec.pattern}/${spec.flags}`,
|
||||||
|
errorMessage: "",
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
evidence: "",
|
||||||
|
errorMessage: `Pattern not found in ${spec.path}: /${spec.pattern}/${spec.flags}`,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
evidence: "",
|
||||||
|
errorMessage: `Cannot read ${fullPath}: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const regexAbsentCheck: CheckHandler = async (check, ctx) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const spec = RegexInFileSpec.parse(check.spec);
|
||||||
|
const fullPath = isAbsolute(spec.path)
|
||||||
|
? spec.path
|
||||||
|
: resolve(ctx.workdir, spec.path);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await readFile(fullPath, "utf8");
|
||||||
|
const regex = new RegExp(spec.pattern, spec.flags);
|
||||||
|
const match = content.match(regex);
|
||||||
|
if (!match) {
|
||||||
|
return {
|
||||||
|
passed: true,
|
||||||
|
evidence: `${spec.path} has no match for /${spec.pattern}/${spec.flags}`,
|
||||||
|
errorMessage: "",
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const lineIdx = content.slice(0, match.index ?? 0).split("\n").length;
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
evidence: "",
|
||||||
|
errorMessage: `Forbidden pattern found in ${spec.path}:${lineIdx}: /${spec.pattern}/${spec.flags}`,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
evidence: "",
|
||||||
|
errorMessage: `Cannot read ${fullPath}: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
18
src/contract/checks/types.ts
Normal file
18
src/contract/checks/types.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { DodCheck } from "../schema.js";
|
||||||
|
|
||||||
|
export interface CheckContext {
|
||||||
|
workdir: string;
|
||||||
|
env: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckOutcome {
|
||||||
|
passed: boolean;
|
||||||
|
evidence: string;
|
||||||
|
errorMessage: string;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CheckHandler = (
|
||||||
|
check: DodCheck,
|
||||||
|
ctx: CheckContext,
|
||||||
|
) => Promise<CheckOutcome>;
|
||||||
103
src/contract/generator.ts
Normal file
103
src/contract/generator.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { ulid } from "ulid";
|
||||||
|
import { SprintContract, type DodCheck } from "./schema.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a sprint markdown file and produce a draft Sprint Contract.
|
||||||
|
*
|
||||||
|
* Heuristics:
|
||||||
|
* - Extracts `## Type` section → contract.type
|
||||||
|
* - Extracts "Tasks" table and creates `file_exists` / `command_success`
|
||||||
|
* stubs for each DoD entry containing keywords like "통과", "pass", "exit".
|
||||||
|
* - Environment prerequisites are NOT inferred from markdown; the user
|
||||||
|
* can add them manually to the draft contract.
|
||||||
|
*
|
||||||
|
* The result is a **draft** — the user must review and `freeze` it
|
||||||
|
* before validation.
|
||||||
|
*/
|
||||||
|
export async function generateDraftContract(
|
||||||
|
sprintMdPath: string,
|
||||||
|
sprintId: string,
|
||||||
|
): Promise<SprintContract> {
|
||||||
|
const raw = await readFile(sprintMdPath, "utf8");
|
||||||
|
|
||||||
|
// Extract type
|
||||||
|
const typeMatch = raw.match(/##\s*Type\s*\n\s*`([^`]+)`/);
|
||||||
|
const rawType = typeMatch?.[1]?.trim() ?? "feature";
|
||||||
|
const type = normalizeType(rawType);
|
||||||
|
|
||||||
|
// Extract non-goals
|
||||||
|
const nonGoalsMatch = raw.match(
|
||||||
|
/##\s*Non-Goals\s*\n([\s\S]*?)(?=\n## |\n---|\n$)/,
|
||||||
|
);
|
||||||
|
const nonGoals: string[] = [];
|
||||||
|
if (nonGoalsMatch?.[1]) {
|
||||||
|
const items = nonGoalsMatch[1].match(/^\s*-\s+(.+)$/gm) ?? [];
|
||||||
|
for (const item of items) {
|
||||||
|
const clean = item.replace(/^\s*-\s+/, "").trim();
|
||||||
|
if (clean) nonGoals.push(clean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default starter checks — user will replace these
|
||||||
|
const checks: DodCheck[] = [
|
||||||
|
{
|
||||||
|
id: "readme-exists",
|
||||||
|
description: "README.md 존재",
|
||||||
|
kind: "file_exists",
|
||||||
|
spec: { path: "README.md" },
|
||||||
|
blocking: true,
|
||||||
|
severity: "major",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "typecheck",
|
||||||
|
description: "TypeScript 타입 체크 통과",
|
||||||
|
kind: "command_success",
|
||||||
|
spec: { command: "pnpm tsc --noEmit", timeoutMs: 60_000, expectExitCode: 0 },
|
||||||
|
blocking: true,
|
||||||
|
severity: "major",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tests-pass",
|
||||||
|
description: "Vitest 전부 통과",
|
||||||
|
kind: "command_success",
|
||||||
|
spec: { command: "pnpm vitest run", timeoutMs: 120_000, expectExitCode: 0 },
|
||||||
|
blocking: true,
|
||||||
|
severity: "major",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const draft = SprintContract.parse({
|
||||||
|
version: "v1",
|
||||||
|
id: ulid(),
|
||||||
|
sprintId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
type,
|
||||||
|
dod: { checks },
|
||||||
|
environmentPrerequisites: [],
|
||||||
|
nonGoals,
|
||||||
|
runtimeValidation: { commands: [] },
|
||||||
|
riskFlags: [],
|
||||||
|
reviewerProfile: "static",
|
||||||
|
approvalGates: { impl: true, review: true, deploy: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeType(raw: string): SprintContract["type"] {
|
||||||
|
const lower = raw.toLowerCase();
|
||||||
|
const allowed = [
|
||||||
|
"scaffold",
|
||||||
|
"feature",
|
||||||
|
"refactor",
|
||||||
|
"bugfix",
|
||||||
|
"migration",
|
||||||
|
"infra",
|
||||||
|
"deploy-only",
|
||||||
|
] as const;
|
||||||
|
for (const t of allowed) {
|
||||||
|
if (lower === t) return t;
|
||||||
|
}
|
||||||
|
return "feature";
|
||||||
|
}
|
||||||
157
src/contract/prerequisite.ts
Normal file
157
src/contract/prerequisite.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { stat } from "node:fs/promises";
|
||||||
|
import { resolve, isAbsolute } from "node:path";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { createConnection } from "node:net";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { EnvPrereq } from "./schema.js";
|
||||||
|
|
||||||
|
export interface PrereqResult {
|
||||||
|
name: string;
|
||||||
|
passed: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommandExistsSpec = z.object({ command: z.string() });
|
||||||
|
const PortOpenSpec = z.object({
|
||||||
|
port: z.number().int().positive(),
|
||||||
|
host: z.string().default("127.0.0.1"),
|
||||||
|
});
|
||||||
|
const EnvVarSpec = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
required: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
const FileExistsPrereqSpec = z.object({ path: z.string() });
|
||||||
|
const HttpReachableSpec = z.object({
|
||||||
|
url: z.string().url(),
|
||||||
|
timeoutMs: z.number().int().positive().default(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function commandExists(cmd: string): Promise<boolean> {
|
||||||
|
return new Promise((resolvePromise) => {
|
||||||
|
const child = spawn("sh", ["-c", `command -v ${cmd}`], {
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
child.on("exit", (code) => resolvePromise(code === 0));
|
||||||
|
child.on("error", () => resolvePromise(false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function portOpen(host: string, port: number): Promise<boolean> {
|
||||||
|
return new Promise((resolvePromise) => {
|
||||||
|
const socket = createConnection({ host, port });
|
||||||
|
let settled = false;
|
||||||
|
const finalize = (ok: boolean) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
socket.destroy();
|
||||||
|
resolvePromise(ok);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => finalize(false), 3000);
|
||||||
|
socket.on("connect", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
finalize(true);
|
||||||
|
});
|
||||||
|
socket.on("error", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
finalize(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function httpReachable(
|
||||||
|
url: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
const res = await fetch(url, { method: "HEAD", signal: controller.signal });
|
||||||
|
clearTimeout(timer);
|
||||||
|
return res.status < 500;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkPrerequisite(
|
||||||
|
prereq: EnvPrereq,
|
||||||
|
workdir: string,
|
||||||
|
): Promise<PrereqResult> {
|
||||||
|
try {
|
||||||
|
switch (prereq.check) {
|
||||||
|
case "command_exists": {
|
||||||
|
const spec = CommandExistsSpec.parse(prereq.spec);
|
||||||
|
const ok = await commandExists(spec.command);
|
||||||
|
return {
|
||||||
|
name: prereq.name,
|
||||||
|
passed: ok,
|
||||||
|
message: ok
|
||||||
|
? `${spec.command} found`
|
||||||
|
: `${spec.command} not found — ${prereq.reason}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "port_open": {
|
||||||
|
const spec = PortOpenSpec.parse(prereq.spec);
|
||||||
|
const ok = await portOpen(spec.host, spec.port);
|
||||||
|
return {
|
||||||
|
name: prereq.name,
|
||||||
|
passed: ok,
|
||||||
|
message: ok
|
||||||
|
? `${spec.host}:${spec.port} reachable`
|
||||||
|
: `${spec.host}:${spec.port} not listening — ${prereq.reason}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "env_var": {
|
||||||
|
const spec = EnvVarSpec.parse(prereq.spec);
|
||||||
|
const val = process.env[spec.name];
|
||||||
|
const ok = !spec.required || (val !== undefined && val !== "");
|
||||||
|
return {
|
||||||
|
name: prereq.name,
|
||||||
|
passed: ok,
|
||||||
|
message: ok
|
||||||
|
? `${spec.name} set`
|
||||||
|
: `${spec.name} missing — ${prereq.reason}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "file_exists": {
|
||||||
|
const spec = FileExistsPrereqSpec.parse(prereq.spec);
|
||||||
|
const fullPath = isAbsolute(spec.path)
|
||||||
|
? spec.path
|
||||||
|
: resolve(workdir, spec.path);
|
||||||
|
try {
|
||||||
|
await stat(fullPath);
|
||||||
|
return { name: prereq.name, passed: true, message: `${fullPath} exists` };
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
name: prereq.name,
|
||||||
|
passed: false,
|
||||||
|
message: `${fullPath} not found — ${prereq.reason}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "http_reachable": {
|
||||||
|
const spec = HttpReachableSpec.parse(prereq.spec);
|
||||||
|
const ok = await httpReachable(spec.url, spec.timeoutMs);
|
||||||
|
return {
|
||||||
|
name: prereq.name,
|
||||||
|
passed: ok,
|
||||||
|
message: ok
|
||||||
|
? `${spec.url} reachable`
|
||||||
|
: `${spec.url} not reachable — ${prereq.reason}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
name: prereq.name,
|
||||||
|
passed: false,
|
||||||
|
message: `Unknown prereq check: ${prereq.check as string}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
name: prereq.name,
|
||||||
|
passed: false,
|
||||||
|
message: `Prereq check errored: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
229
src/contract/schema.ts
Normal file
229
src/contract/schema.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Check kind-specific spec schemas
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const FileExistsSpec = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CommandSuccessSpec = z.object({
|
||||||
|
command: z.string(),
|
||||||
|
cwd: z.string().optional(),
|
||||||
|
env: z.record(z.string()).optional(),
|
||||||
|
timeoutMs: z.number().int().positive().default(60_000),
|
||||||
|
expectExitCode: z.number().int().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RegexInFileSpec = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
pattern: z.string(),
|
||||||
|
flags: z.string().default(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RegexAbsentSpec = RegexInFileSpec;
|
||||||
|
|
||||||
|
export const HttpStatusSpec = z.object({
|
||||||
|
url: z.string().url(),
|
||||||
|
expectStatus: z.number().int().positive().default(200),
|
||||||
|
timeoutMs: z.number().int().positive().default(10_000),
|
||||||
|
method: z.enum(["GET", "HEAD", "POST"]).default("GET"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DbQuerySpec = z.object({
|
||||||
|
query: z.string(),
|
||||||
|
connectionString: z.string().optional(), // falls back to DATABASE_URL
|
||||||
|
expectMinRows: z.number().int().min(0).default(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProcessListeningSpec = z.object({
|
||||||
|
port: z.number().int().positive(),
|
||||||
|
host: z.string().default("127.0.0.1"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ArtifactSchemaSpec = z.object({
|
||||||
|
artifactPath: z.string(),
|
||||||
|
schemaName: z.string(), // Registered schema name
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ManualCheckSpec = z.object({
|
||||||
|
question: z.string(),
|
||||||
|
guidance: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// DoD check (one item)
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const DodCheckKind = z.enum([
|
||||||
|
"file_exists",
|
||||||
|
"command_success",
|
||||||
|
"regex_in_file",
|
||||||
|
"regex_absent",
|
||||||
|
"http_status",
|
||||||
|
"db_query",
|
||||||
|
"process_listening",
|
||||||
|
"artifact_schema",
|
||||||
|
"manual",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type DodCheckKind = z.infer<typeof DodCheckKind>;
|
||||||
|
|
||||||
|
export const DodCheck = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
description: z.string(),
|
||||||
|
kind: DodCheckKind,
|
||||||
|
spec: z.unknown(),
|
||||||
|
blocking: z.boolean().default(true),
|
||||||
|
severity: z.enum(["critical", "major", "minor"]).default("major"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DodCheck = z.infer<typeof DodCheck>;
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Environment prerequisite
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const PrereqKind = z.enum([
|
||||||
|
"command_exists",
|
||||||
|
"port_open",
|
||||||
|
"env_var",
|
||||||
|
"file_exists",
|
||||||
|
"http_reachable",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type PrereqKind = z.infer<typeof PrereqKind>;
|
||||||
|
|
||||||
|
export const EnvPrereq = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
check: PrereqKind,
|
||||||
|
spec: z.unknown(),
|
||||||
|
reason: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type EnvPrereq = z.infer<typeof EnvPrereq>;
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Runtime validation command
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const RuntimeValidationCommand = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
command: z.string(),
|
||||||
|
cwd: z.string().optional(),
|
||||||
|
env: z.record(z.string()).optional(),
|
||||||
|
timeoutMs: z.number().int().positive().default(60_000),
|
||||||
|
expectExitCode: z.number().int().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Sprint Contract (top level)
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const SprintContract = z.object({
|
||||||
|
version: z.literal("v1"),
|
||||||
|
id: z.string().min(1),
|
||||||
|
sprintId: z.string().min(1),
|
||||||
|
createdAt: z.string().datetime(),
|
||||||
|
type: z.enum([
|
||||||
|
"scaffold",
|
||||||
|
"feature",
|
||||||
|
"refactor",
|
||||||
|
"bugfix",
|
||||||
|
"migration",
|
||||||
|
"infra",
|
||||||
|
"deploy-only",
|
||||||
|
]),
|
||||||
|
|
||||||
|
dod: z.object({
|
||||||
|
checks: z.array(DodCheck),
|
||||||
|
}),
|
||||||
|
|
||||||
|
environmentPrerequisites: z.array(EnvPrereq).default([]),
|
||||||
|
|
||||||
|
nonGoals: z.array(z.string()).default([]),
|
||||||
|
|
||||||
|
runtimeValidation: z
|
||||||
|
.object({
|
||||||
|
commands: z.array(RuntimeValidationCommand),
|
||||||
|
})
|
||||||
|
.default({ commands: [] }),
|
||||||
|
|
||||||
|
riskFlags: z
|
||||||
|
.array(
|
||||||
|
z.enum([
|
||||||
|
"security-sensitive",
|
||||||
|
"data-migration",
|
||||||
|
"breaking-change",
|
||||||
|
"ux-regression",
|
||||||
|
"performance-critical",
|
||||||
|
"needs-spike",
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.default([]),
|
||||||
|
|
||||||
|
reviewerProfile: z
|
||||||
|
.enum(["static", "runtime", "browser"])
|
||||||
|
.default("static"),
|
||||||
|
|
||||||
|
approvalGates: z
|
||||||
|
.object({
|
||||||
|
impl: z.boolean().default(true),
|
||||||
|
review: z.boolean().default(true),
|
||||||
|
deploy: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.default({ impl: true, review: true, deploy: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SprintContract = z.infer<typeof SprintContract>;
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Validation result
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const CheckResult = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
kind: DodCheckKind,
|
||||||
|
passed: z.boolean(),
|
||||||
|
blocking: z.boolean(),
|
||||||
|
severity: z.enum(["critical", "major", "minor"]),
|
||||||
|
evidence: z.string().default(""),
|
||||||
|
errorMessage: z.string().default(""),
|
||||||
|
durationMs: z.number().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CheckResult = z.infer<typeof CheckResult>;
|
||||||
|
|
||||||
|
export const ValidationResult = z.object({
|
||||||
|
contractId: z.string(),
|
||||||
|
verdict: z.enum(["PASS", "FAIL", "ABORT_PRECHECK"]),
|
||||||
|
startedAt: z.string().datetime(),
|
||||||
|
completedAt: z.string().datetime(),
|
||||||
|
prerequisiteResults: z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
passed: z.boolean(),
|
||||||
|
message: z.string().default(""),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
checkResults: z.array(CheckResult),
|
||||||
|
runtimeCommandResults: z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
passed: z.boolean(),
|
||||||
|
exitCode: z.number(),
|
||||||
|
stdout: z.string().default(""),
|
||||||
|
stderr: z.string().default(""),
|
||||||
|
durationMs: z.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
summary: z.object({
|
||||||
|
total: z.number(),
|
||||||
|
passed: z.number(),
|
||||||
|
failed: z.number(),
|
||||||
|
blockingFailed: z.number(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ValidationResult = z.infer<typeof ValidationResult>;
|
||||||
114
src/contract/store.ts
Normal file
114
src/contract/store.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { writeFile, readFile, mkdir, chmod } from "node:fs/promises";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { SprintContract } from "./schema.js";
|
||||||
|
import { getPrisma } from "../orchestrator/persist.js";
|
||||||
|
import { childLogger } from "../logger.js";
|
||||||
|
|
||||||
|
const log = childLogger({ module: "contract-store" });
|
||||||
|
|
||||||
|
const CONTRACTS_DIR = ".rails/contracts";
|
||||||
|
|
||||||
|
export function contractFilePath(railsDir: string, contractId: string): string {
|
||||||
|
return join(railsDir, CONTRACTS_DIR, `${contractId}.sprint-contract.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a draft contract to file + DB.
|
||||||
|
* The contract is mutable until `freezeContract()` is called.
|
||||||
|
*/
|
||||||
|
export async function saveDraftContract(
|
||||||
|
railsDir: string,
|
||||||
|
contract: SprintContract,
|
||||||
|
pipelineId?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const filePath = contractFilePath(railsDir, contract.id);
|
||||||
|
await mkdir(dirname(filePath), { recursive: true });
|
||||||
|
await writeFile(filePath, JSON.stringify(contract, null, 2), "utf8");
|
||||||
|
|
||||||
|
if (pipelineId) {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
await prisma.contract.create({
|
||||||
|
data: {
|
||||||
|
id: contract.id,
|
||||||
|
pipelineId,
|
||||||
|
sprintId: contract.sprintId,
|
||||||
|
version: contract.version,
|
||||||
|
bodyJson: JSON.stringify(contract),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info({ contractId: contract.id, sprintId: contract.sprintId }, "Draft contract saved");
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a contract from file (file is source of truth for validation).
|
||||||
|
*/
|
||||||
|
export async function loadContract(
|
||||||
|
railsDir: string,
|
||||||
|
contractId: string,
|
||||||
|
): Promise<SprintContract> {
|
||||||
|
const filePath = contractFilePath(railsDir, contractId);
|
||||||
|
const raw = await readFile(filePath, "utf8");
|
||||||
|
return SprintContract.parse(JSON.parse(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freeze a contract: mark as immutable in DB and make file read-only.
|
||||||
|
*/
|
||||||
|
export async function freezeContract(
|
||||||
|
railsDir: string,
|
||||||
|
contractId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const filePath = contractFilePath(railsDir, contractId);
|
||||||
|
|
||||||
|
// Make file read-only
|
||||||
|
await chmod(filePath, 0o444);
|
||||||
|
|
||||||
|
// Update DB if contract exists there
|
||||||
|
try {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
await prisma.contract.update({
|
||||||
|
where: { id: contractId },
|
||||||
|
data: { frozenAt: new Date() },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// DB entry may not exist for local-only contracts
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info({ contractId }, "Contract frozen");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a draft contract (allowed only if not frozen).
|
||||||
|
*/
|
||||||
|
export async function updateDraftContract(
|
||||||
|
railsDir: string,
|
||||||
|
contract: SprintContract,
|
||||||
|
): Promise<void> {
|
||||||
|
const filePath = contractFilePath(railsDir, contract.id);
|
||||||
|
|
||||||
|
// Check frozen status in DB
|
||||||
|
try {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
const existing = await prisma.contract.findUnique({
|
||||||
|
where: { id: contract.id },
|
||||||
|
});
|
||||||
|
if (existing?.frozenAt) {
|
||||||
|
throw new Error(
|
||||||
|
`Contract ${contract.id} is frozen since ${existing.frozenAt.toISOString()} and cannot be modified.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await prisma.contract.update({
|
||||||
|
where: { id: contract.id },
|
||||||
|
data: { bodyJson: JSON.stringify(contract) },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.message.includes("frozen")) throw err;
|
||||||
|
// Ignore other DB errors for local-only contracts
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write file (will fail if file was chmod 0444, which means already frozen)
|
||||||
|
await writeFile(filePath, JSON.stringify(contract, null, 2), "utf8");
|
||||||
|
}
|
||||||
157
src/contract/validator.ts
Normal file
157
src/contract/validator.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import {
|
||||||
|
SprintContract,
|
||||||
|
type ValidationResult,
|
||||||
|
type CheckResult,
|
||||||
|
} from "./schema.js";
|
||||||
|
import { CHECK_HANDLERS, execCommand } from "./checks/index.js";
|
||||||
|
import { checkPrerequisite } from "./prerequisite.js";
|
||||||
|
import { childLogger } from "../logger.js";
|
||||||
|
|
||||||
|
const log = childLogger({ module: "contract-validator" });
|
||||||
|
|
||||||
|
export interface ValidateOptions {
|
||||||
|
workdir: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the full validation pipeline for a sprint contract:
|
||||||
|
* 1. Environment prerequisites (any failure → ABORT_PRECHECK)
|
||||||
|
* 2. Runtime validation commands
|
||||||
|
* 3. DoD checks
|
||||||
|
*
|
||||||
|
* Returns a ValidationResult with verdict PASS / FAIL / ABORT_PRECHECK.
|
||||||
|
*/
|
||||||
|
export async function validateContract(
|
||||||
|
contractJson: unknown,
|
||||||
|
opts: ValidateOptions,
|
||||||
|
): Promise<ValidationResult> {
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
const contract = SprintContract.parse(contractJson);
|
||||||
|
const env = opts.env ?? (process.env as Record<string, string>);
|
||||||
|
|
||||||
|
log.info({ contractId: contract.id, sprintId: contract.sprintId }, "Validation started");
|
||||||
|
|
||||||
|
// ── Step 1. Environment prerequisites ──
|
||||||
|
const prerequisiteResults = [];
|
||||||
|
for (const prereq of contract.environmentPrerequisites) {
|
||||||
|
const r = await checkPrerequisite(prereq, opts.workdir);
|
||||||
|
prerequisiteResults.push(r);
|
||||||
|
if (!r.passed) {
|
||||||
|
log.warn({ prereq: prereq.name, message: r.message }, "Prereq failed");
|
||||||
|
return {
|
||||||
|
contractId: contract.id,
|
||||||
|
verdict: "ABORT_PRECHECK",
|
||||||
|
startedAt,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
prerequisiteResults,
|
||||||
|
checkResults: [],
|
||||||
|
runtimeCommandResults: [],
|
||||||
|
summary: { total: 0, passed: 0, failed: 0, blockingFailed: 1 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 2. Runtime validation commands ──
|
||||||
|
const runtimeCommandResults: ValidationResult["runtimeCommandResults"] = [];
|
||||||
|
for (const cmd of contract.runtimeValidation.commands) {
|
||||||
|
const cmdStart = Date.now();
|
||||||
|
const result = await execCommand(cmd.command, {
|
||||||
|
cwd: cmd.cwd ?? opts.workdir,
|
||||||
|
env: { ...env, ...(cmd.env ?? {}) },
|
||||||
|
timeoutMs: cmd.timeoutMs,
|
||||||
|
});
|
||||||
|
runtimeCommandResults.push({
|
||||||
|
name: cmd.name,
|
||||||
|
passed: !result.timedOut && result.exitCode === cmd.expectExitCode,
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
stdout: result.stdout.slice(0, 2000),
|
||||||
|
stderr: result.stderr.slice(0, 2000),
|
||||||
|
durationMs: Date.now() - cmdStart,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 3. DoD checks ──
|
||||||
|
const checkResults: CheckResult[] = [];
|
||||||
|
for (const check of contract.dod.checks) {
|
||||||
|
const handler = CHECK_HANDLERS[check.kind];
|
||||||
|
if (!handler) {
|
||||||
|
checkResults.push({
|
||||||
|
id: check.id,
|
||||||
|
kind: check.kind,
|
||||||
|
passed: false,
|
||||||
|
blocking: check.blocking,
|
||||||
|
severity: check.severity,
|
||||||
|
evidence: "",
|
||||||
|
errorMessage: `No handler registered for kind: ${check.kind}`,
|
||||||
|
durationMs: 0,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const outcome = await handler(check, { workdir: opts.workdir, env });
|
||||||
|
checkResults.push({
|
||||||
|
id: check.id,
|
||||||
|
kind: check.kind,
|
||||||
|
passed: outcome.passed,
|
||||||
|
blocking: check.blocking,
|
||||||
|
severity: check.severity,
|
||||||
|
evidence: outcome.evidence,
|
||||||
|
errorMessage: outcome.errorMessage,
|
||||||
|
durationMs: outcome.durationMs,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
checkResults.push({
|
||||||
|
id: check.id,
|
||||||
|
kind: check.kind,
|
||||||
|
passed: false,
|
||||||
|
blocking: check.blocking,
|
||||||
|
severity: check.severity,
|
||||||
|
evidence: "",
|
||||||
|
errorMessage: `Handler threw: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
durationMs: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Aggregate ──
|
||||||
|
const allResults = [
|
||||||
|
...checkResults,
|
||||||
|
...runtimeCommandResults.map((r) => ({
|
||||||
|
id: `runtime:${r.name}`,
|
||||||
|
kind: "command_success" as const,
|
||||||
|
passed: r.passed,
|
||||||
|
blocking: true,
|
||||||
|
severity: "major" as const,
|
||||||
|
evidence: r.passed ? `${r.name} exit ${r.exitCode}` : "",
|
||||||
|
errorMessage: r.passed ? "" : `${r.name} failed: ${r.stderr.slice(0, 200)}`,
|
||||||
|
durationMs: r.durationMs,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const total = allResults.length;
|
||||||
|
const passed = allResults.filter((r) => r.passed).length;
|
||||||
|
const failed = total - passed;
|
||||||
|
const blockingFailed = allResults.filter(
|
||||||
|
(r) => !r.passed && r.blocking,
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const verdict = blockingFailed === 0 ? "PASS" : "FAIL";
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{ contractId: contract.id, verdict, total, passed, failed, blockingFailed },
|
||||||
|
"Validation complete",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
contractId: contract.id,
|
||||||
|
verdict,
|
||||||
|
startedAt,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
prerequisiteResults,
|
||||||
|
checkResults,
|
||||||
|
runtimeCommandResults,
|
||||||
|
summary: { total, passed, failed, blockingFailed },
|
||||||
|
};
|
||||||
|
}
|
||||||
81
src/enforcement/guard.ts
Normal file
81
src/enforcement/guard.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import {
|
||||||
|
readSkillContext,
|
||||||
|
isContextExpired,
|
||||||
|
contextAgeSeconds,
|
||||||
|
type SkillContext,
|
||||||
|
} from "./skill-context.js";
|
||||||
|
import { appendTrace } from "./skill-trace.js";
|
||||||
|
import { childLogger } from "../logger.js";
|
||||||
|
|
||||||
|
const log = childLogger({ module: "guard" });
|
||||||
|
|
||||||
|
export interface GuardResult {
|
||||||
|
allowed: boolean;
|
||||||
|
reason: string;
|
||||||
|
context: SkillContext | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the current operation is allowed based on skill context.
|
||||||
|
* Used by pre-tool hook to gate Write/Edit/Bash calls.
|
||||||
|
*/
|
||||||
|
export async function checkGuard(
|
||||||
|
railsDir: string,
|
||||||
|
toolName: string,
|
||||||
|
opts?: { sessionId?: string },
|
||||||
|
): Promise<GuardResult> {
|
||||||
|
// Escape hatch
|
||||||
|
if (process.env["RAILS_ENFORCE"] === "off") {
|
||||||
|
log.warn({ toolName }, "Enforcement disabled via RAILS_ENFORCE=off");
|
||||||
|
await appendTrace(railsDir, {
|
||||||
|
ts: Date.now(),
|
||||||
|
tool: toolName,
|
||||||
|
sessionId: opts?.sessionId ?? "",
|
||||||
|
blocked: false,
|
||||||
|
reason: "enforcement-off",
|
||||||
|
});
|
||||||
|
return { allowed: true, reason: "enforcement-off", context: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = await readSkillContext(railsDir);
|
||||||
|
|
||||||
|
if (!ctx) {
|
||||||
|
const reason = "No skill context found. Run /rails or rails skill-context create first.";
|
||||||
|
log.warn({ toolName }, reason);
|
||||||
|
await appendTrace(railsDir, {
|
||||||
|
ts: Date.now(),
|
||||||
|
tool: toolName,
|
||||||
|
sessionId: opts?.sessionId ?? "",
|
||||||
|
blocked: true,
|
||||||
|
reason: "no-context",
|
||||||
|
});
|
||||||
|
return { allowed: false, reason, context: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isContextExpired(ctx)) {
|
||||||
|
const age = contextAgeSeconds(ctx);
|
||||||
|
const reason = `Skill context expired (age: ${age}s, ttl: ${ctx.ttlSeconds}s). Re-enter the skill.`;
|
||||||
|
log.warn({ toolName, age, ttl: ctx.ttlSeconds }, reason);
|
||||||
|
await appendTrace(railsDir, {
|
||||||
|
ts: Date.now(),
|
||||||
|
tool: toolName,
|
||||||
|
sessionId: opts?.sessionId ?? "",
|
||||||
|
pipelineId: ctx.pipelineId,
|
||||||
|
blocked: true,
|
||||||
|
reason: "context-expired",
|
||||||
|
});
|
||||||
|
return { allowed: false, reason, context: ctx };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid context
|
||||||
|
await appendTrace(railsDir, {
|
||||||
|
ts: Date.now(),
|
||||||
|
tool: toolName,
|
||||||
|
sessionId: opts?.sessionId ?? "",
|
||||||
|
pipelineId: ctx.pipelineId,
|
||||||
|
blocked: false,
|
||||||
|
reason: "ok",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { allowed: true, reason: "ok", context: ctx };
|
||||||
|
}
|
||||||
81
src/enforcement/skill-context.ts
Normal file
81
src/enforcement/skill-context.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { childLogger } from "../logger.js";
|
||||||
|
|
||||||
|
const log = childLogger({ module: "skill-context" });
|
||||||
|
|
||||||
|
export const SkillContext = z.object({
|
||||||
|
skillName: z.string(),
|
||||||
|
subcommand: z.string().default(""),
|
||||||
|
pipelineId: z.string().default(""),
|
||||||
|
contractId: z.string().default(""),
|
||||||
|
sessionId: z.string().default(""),
|
||||||
|
createdAt: z.string().datetime(),
|
||||||
|
ttlSeconds: z.number().int().positive().default(300),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SkillContext = z.infer<typeof SkillContext>;
|
||||||
|
|
||||||
|
const CONTEXT_FILENAME = "skill-context.json";
|
||||||
|
|
||||||
|
function contextPath(railsDir: string): string {
|
||||||
|
return join(railsDir, ".rails", CONTEXT_FILENAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSkillContext(
|
||||||
|
railsDir: string,
|
||||||
|
data: {
|
||||||
|
skillName: string;
|
||||||
|
subcommand?: string;
|
||||||
|
pipelineId?: string;
|
||||||
|
contractId?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
ttlSeconds?: number;
|
||||||
|
},
|
||||||
|
): Promise<SkillContext> {
|
||||||
|
const parsed = SkillContext.parse({
|
||||||
|
...data,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const filePath = contextPath(railsDir);
|
||||||
|
await mkdir(dirname(filePath), { recursive: true });
|
||||||
|
await writeFile(filePath, JSON.stringify(parsed, null, 2), "utf8");
|
||||||
|
|
||||||
|
log.info({ skillName: parsed.skillName, pipelineId: parsed.pipelineId }, "Skill context created");
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readSkillContext(
|
||||||
|
railsDir: string,
|
||||||
|
): Promise<SkillContext | null> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(contextPath(railsDir), "utf8");
|
||||||
|
return SkillContext.parse(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSkillContext(railsDir: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await unlink(contextPath(railsDir));
|
||||||
|
log.info("Skill context cleared");
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isContextExpired(ctx: SkillContext): boolean {
|
||||||
|
const createdMs = new Date(ctx.createdAt).getTime();
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const elapsedSeconds = (nowMs - createdMs) / 1000;
|
||||||
|
return elapsedSeconds > ctx.ttlSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contextAgeSeconds(ctx: SkillContext): number {
|
||||||
|
const createdMs = new Date(ctx.createdAt).getTime();
|
||||||
|
return Math.floor((Date.now() - createdMs) / 1000);
|
||||||
|
}
|
||||||
75
src/enforcement/skill-trace.ts
Normal file
75
src/enforcement/skill-trace.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { appendFile, readFile, mkdir } from "node:fs/promises";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { childLogger } from "../logger.js";
|
||||||
|
|
||||||
|
const log = childLogger({ module: "skill-trace" });
|
||||||
|
|
||||||
|
export const TraceEntry = z.object({
|
||||||
|
ts: z.number(),
|
||||||
|
tool: z.string(),
|
||||||
|
cwd: z.string().default(""),
|
||||||
|
sessionId: z.string().default(""),
|
||||||
|
pipelineId: z.string().default(""),
|
||||||
|
blocked: z.boolean().default(false),
|
||||||
|
reason: z.string().default(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TraceEntry = z.infer<typeof TraceEntry>;
|
||||||
|
|
||||||
|
const TRACE_FILENAME = "skill-trace.jsonl";
|
||||||
|
|
||||||
|
function tracePath(railsDir: string): string {
|
||||||
|
return join(railsDir, ".rails", TRACE_FILENAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendTrace(
|
||||||
|
railsDir: string,
|
||||||
|
entry: {
|
||||||
|
ts: number;
|
||||||
|
tool: string;
|
||||||
|
cwd?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
pipelineId?: string;
|
||||||
|
blocked: boolean;
|
||||||
|
reason?: string;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
const filePath = tracePath(railsDir);
|
||||||
|
await mkdir(dirname(filePath), { recursive: true });
|
||||||
|
|
||||||
|
const parsed = TraceEntry.parse(entry);
|
||||||
|
await appendFile(filePath, JSON.stringify(parsed) + "\n", "utf8");
|
||||||
|
|
||||||
|
if (parsed.blocked) {
|
||||||
|
log.warn({ tool: parsed.tool, reason: parsed.reason }, "Tool call blocked");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readTrace(
|
||||||
|
railsDir: string,
|
||||||
|
opts?: { pipelineId?: string; limit?: number },
|
||||||
|
): Promise<TraceEntry[]> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(tracePath(railsDir), "utf8");
|
||||||
|
const lines = raw.trim().split("\n").filter(Boolean);
|
||||||
|
let entries = lines.map((line) => TraceEntry.parse(JSON.parse(line)));
|
||||||
|
|
||||||
|
if (opts?.pipelineId) {
|
||||||
|
entries = entries.filter((e) => e.pipelineId === opts.pipelineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts?.limit) {
|
||||||
|
entries = entries.slice(-opts.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countBlocked(railsDir: string): Promise<number> {
|
||||||
|
const entries = await readTrace(railsDir);
|
||||||
|
return entries.filter((e) => e.blocked).length;
|
||||||
|
}
|
||||||
83
src/handoff/build.ts
Normal file
83
src/handoff/build.ts
Normal 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;
|
||||||
|
}
|
||||||
120
src/handoff/discord-transport.ts
Normal file
120
src/handoff/discord-transport.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import type {
|
||||||
|
SisterTransport,
|
||||||
|
HealthStatus,
|
||||||
|
} from "./transport.js";
|
||||||
|
import {
|
||||||
|
HandoffMessage,
|
||||||
|
type InvokeRequest,
|
||||||
|
} from "./message.js";
|
||||||
|
import { childLogger } from "../logger.js";
|
||||||
|
|
||||||
|
const log = childLogger({ module: "discord-transport" });
|
||||||
|
|
||||||
|
export interface DiscordTransportOptions {
|
||||||
|
token: string;
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
/**
|
||||||
|
* Dependency-injected message poster. Real usage passes a discord.js client;
|
||||||
|
* tests pass a fake. Rails invokes this to put the request marker into
|
||||||
|
* the agent channel and waits for a reply marker.
|
||||||
|
*/
|
||||||
|
poster: DiscordPoster;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordPoster {
|
||||||
|
postMessage(channelId: string, content: string): Promise<string>;
|
||||||
|
waitForResult(opts: {
|
||||||
|
channelId: string;
|
||||||
|
pipelineId: string;
|
||||||
|
stage: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}): Promise<string>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode an invoke request as a marker block that the agent bot can parse
|
||||||
|
* without LLM interpretation.
|
||||||
|
*/
|
||||||
|
export function encodeInvokeMarker(req: InvokeRequest): string {
|
||||||
|
const json = JSON.stringify(req);
|
||||||
|
return (
|
||||||
|
"<!-- rails:invoke v1 -->\n" +
|
||||||
|
"```json\n" +
|
||||||
|
json +
|
||||||
|
"\n```\n" +
|
||||||
|
"<!-- /rails:invoke -->"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a result marker from a message body.
|
||||||
|
* Returns the parsed HandoffMessage or throws on invalid payload.
|
||||||
|
*/
|
||||||
|
export function decodeResultMarker(body: string): HandoffMessage {
|
||||||
|
const match = body.match(
|
||||||
|
/<!--\s*rails:result\s+v1\s*-->\s*```json\s*([\s\S]*?)```\s*<!--\s*\/rails:result\s*-->/,
|
||||||
|
);
|
||||||
|
if (!match?.[1]) {
|
||||||
|
throw new Error("No rails:result marker found in message");
|
||||||
|
}
|
||||||
|
const json = match[1].trim();
|
||||||
|
const data = JSON.parse(json) as unknown;
|
||||||
|
return HandoffMessage.parse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DiscordTransport implements SisterTransport {
|
||||||
|
readonly name = "discord";
|
||||||
|
private readonly opts: Required<Omit<DiscordTransportOptions, "poster">> & {
|
||||||
|
poster: DiscordPoster;
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(opts: DiscordTransportOptions) {
|
||||||
|
this.opts = {
|
||||||
|
token: opts.token,
|
||||||
|
guildId: opts.guildId,
|
||||||
|
channelId: opts.channelId,
|
||||||
|
timeoutMs: opts.timeoutMs ?? 30_000,
|
||||||
|
poster: opts.poster,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async invoke(
|
||||||
|
req: InvokeRequest,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<HandoffMessage> {
|
||||||
|
const marker = encodeInvokeMarker(req);
|
||||||
|
const content = marker + "\n\n" + this.humanPreamble(req);
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{ stage: req.stage, pipelineId: req.pipelineId },
|
||||||
|
"Dispatching invoke via discord",
|
||||||
|
);
|
||||||
|
await this.opts.poster.postMessage(this.opts.channelId, content);
|
||||||
|
|
||||||
|
const replyBody = await this.opts.poster.waitForResult({
|
||||||
|
channelId: this.opts.channelId,
|
||||||
|
pipelineId: req.pipelineId,
|
||||||
|
stage: req.stage,
|
||||||
|
timeoutMs: req.timeoutMs || this.opts.timeoutMs,
|
||||||
|
...(signal && { signal }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return decodeResultMarker(replyBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
async health(): Promise<HealthStatus> {
|
||||||
|
return { alive: true, latencyMs: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await this.opts.poster.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private humanPreamble(req: InvokeRequest): string {
|
||||||
|
return `📋 Task dispatched — stage: **${req.stage}** | pipeline: \`${req.pipelineId.slice(0, 8)}\` | timeout: ${req.timeoutMs}ms\n${req.task.title}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/handoff/http-transport.ts
Normal file
109
src/handoff/http-transport.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/handoff/message.ts
Normal file
95
src/handoff/message.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HandoffMessage — the structured result returned by an agent invocation.
|
||||||
|
* Each stage has its own payload shape.
|
||||||
|
*
|
||||||
|
* Rails will always parse agent responses through this discriminated union;
|
||||||
|
* any response that fails validation is treated as an ERROR event.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PlanHandoffPayload = z.object({
|
||||||
|
planDir: z.string(),
|
||||||
|
sprintId: z.string(),
|
||||||
|
contractId: z.string().default(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ImplementHandoffPayload = z.object({
|
||||||
|
branch: z.string(),
|
||||||
|
commits: z.array(z.string()),
|
||||||
|
workdir: z.string().default(""),
|
||||||
|
selfTestReport: z.record(z.unknown()).default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ReviewIssueLite = z.object({
|
||||||
|
severity: z.enum(["critical", "major", "minor", "recommendation"]),
|
||||||
|
message: z.string(),
|
||||||
|
file: z.string().optional(),
|
||||||
|
line: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ReviewHandoffPayload = z.object({
|
||||||
|
artifactPath: z.string().default(""),
|
||||||
|
checklistResults: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
passed: z.boolean(),
|
||||||
|
note: z.string().default(""),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.default([]),
|
||||||
|
issues: z.array(ReviewIssueLite).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DeployHandoffPayload = z.object({
|
||||||
|
deployArtifactPath: z.string().default(""),
|
||||||
|
projectType: z.string().default(""),
|
||||||
|
verificationResults: z.record(z.unknown()).default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const HandoffMessage = z.discriminatedUnion("stage", [
|
||||||
|
z.object({
|
||||||
|
stage: z.literal("plan"),
|
||||||
|
verdict: z.enum(["PLAN_READY", "ABORT"]),
|
||||||
|
payload: PlanHandoffPayload.optional(),
|
||||||
|
abortReason: z.string().default(""),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
stage: z.literal("implement"),
|
||||||
|
verdict: z.enum(["IMPL_DONE", "ERROR"]),
|
||||||
|
payload: ImplementHandoffPayload.optional(),
|
||||||
|
errorReason: z.string().default(""),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
stage: z.literal("review"),
|
||||||
|
verdict: z.enum(["APPROVE", "REQUEST_CHANGES", "ABORT"]),
|
||||||
|
payload: ReviewHandoffPayload.optional(),
|
||||||
|
abortReason: z.string().default(""),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
stage: z.literal("deploy"),
|
||||||
|
verdict: z.enum(["DEPLOY_DONE", "DEPLOY_FAILED"]),
|
||||||
|
payload: DeployHandoffPayload.optional(),
|
||||||
|
errorReason: z.string().default(""),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type HandoffMessage = z.infer<typeof HandoffMessage>;
|
||||||
|
|
||||||
|
export const InvokeRequest = z.object({
|
||||||
|
pipelineId: z.string(),
|
||||||
|
contractId: z.string().default(""),
|
||||||
|
stage: z.enum(["plan", "implement", "review", "deploy"]),
|
||||||
|
role: z.string(),
|
||||||
|
sprintId: z.string().default(""),
|
||||||
|
task: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string().default(""),
|
||||||
|
workdir: z.string().default(""),
|
||||||
|
}),
|
||||||
|
timeoutMs: z.number().int().positive().default(30_000),
|
||||||
|
structuredOutput: z.literal(true).default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InvokeRequest = z.infer<typeof InvokeRequest>;
|
||||||
100
src/handoff/mock-transport.ts
Normal file
100
src/handoff/mock-transport.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import type {
|
||||||
|
SisterTransport,
|
||||||
|
HealthStatus,
|
||||||
|
} from "./transport.js";
|
||||||
|
import type { HandoffMessage, InvokeRequest } from "./message.js";
|
||||||
|
import { childLogger } from "../logger.js";
|
||||||
|
|
||||||
|
const log = childLogger({ module: "mock-transport" });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MockTransport — deterministic fake transport for testing and local dev.
|
||||||
|
*
|
||||||
|
* By default it returns successful HandoffMessages for each stage:
|
||||||
|
* plan → PLAN_READY
|
||||||
|
* implement → IMPL_DONE
|
||||||
|
* review → APPROVE
|
||||||
|
* deploy → DEPLOY_DONE
|
||||||
|
*
|
||||||
|
* Scenarios can be overridden per pipelineId or per stage via constructor.
|
||||||
|
*/
|
||||||
|
export class MockTransport implements SisterTransport {
|
||||||
|
readonly name = "mock";
|
||||||
|
private scenarios: Map<string, HandoffMessage>;
|
||||||
|
|
||||||
|
constructor(overrides: Record<string, HandoffMessage> = {}) {
|
||||||
|
this.scenarios = new Map(Object.entries(overrides));
|
||||||
|
}
|
||||||
|
|
||||||
|
setScenario(key: string, message: HandoffMessage): void {
|
||||||
|
this.scenarios.set(key, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async invoke(req: InvokeRequest): Promise<HandoffMessage> {
|
||||||
|
const key = `${req.pipelineId}:${req.stage}`;
|
||||||
|
const override = this.scenarios.get(key) ?? this.scenarios.get(req.stage);
|
||||||
|
if (override) {
|
||||||
|
log.debug({ stage: req.stage, key }, "Mock scenario override");
|
||||||
|
return override;
|
||||||
|
}
|
||||||
|
return defaultSuccess(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async health(): Promise<HealthStatus> {
|
||||||
|
return { alive: true, latencyMs: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultSuccess(req: InvokeRequest): HandoffMessage {
|
||||||
|
switch (req.stage) {
|
||||||
|
case "plan":
|
||||||
|
return {
|
||||||
|
stage: "plan",
|
||||||
|
verdict: "PLAN_READY",
|
||||||
|
payload: {
|
||||||
|
planDir: "/tmp/mock-plans",
|
||||||
|
sprintId: req.sprintId || "SPRINT-MOCK",
|
||||||
|
contractId: req.contractId || "",
|
||||||
|
},
|
||||||
|
abortReason: "",
|
||||||
|
};
|
||||||
|
case "implement":
|
||||||
|
return {
|
||||||
|
stage: "implement",
|
||||||
|
verdict: "IMPL_DONE",
|
||||||
|
payload: {
|
||||||
|
branch: "feature/mock",
|
||||||
|
commits: ["mockc01"],
|
||||||
|
workdir: req.task.workdir || "/tmp",
|
||||||
|
selfTestReport: { typecheck: "pass", tests: "pass" },
|
||||||
|
},
|
||||||
|
errorReason: "",
|
||||||
|
};
|
||||||
|
case "review":
|
||||||
|
return {
|
||||||
|
stage: "review",
|
||||||
|
verdict: "APPROVE",
|
||||||
|
payload: {
|
||||||
|
artifactPath: "/tmp/mock-review.json",
|
||||||
|
checklistResults: [],
|
||||||
|
issues: [],
|
||||||
|
},
|
||||||
|
abortReason: "",
|
||||||
|
};
|
||||||
|
case "deploy":
|
||||||
|
return {
|
||||||
|
stage: "deploy",
|
||||||
|
verdict: "DEPLOY_DONE",
|
||||||
|
payload: {
|
||||||
|
deployArtifactPath: "/tmp/mock-deploy.json",
|
||||||
|
projectType: "mock",
|
||||||
|
verificationResults: {},
|
||||||
|
},
|
||||||
|
errorReason: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/handoff/transport.ts
Normal file
14
src/handoff/transport.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { HandoffMessage, InvokeRequest } from "./message.js";
|
||||||
|
|
||||||
|
export interface HealthStatus {
|
||||||
|
alive: boolean;
|
||||||
|
latencyMs: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SisterTransport {
|
||||||
|
readonly name: string;
|
||||||
|
invoke(req: InvokeRequest, signal?: AbortSignal): Promise<HandoffMessage>;
|
||||||
|
health(role: string): Promise<HealthStatus>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
173
src/hierarchy/complexity.ts
Normal file
173
src/hierarchy/complexity.ts
Normal 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
191
src/hierarchy/planner.ts
Normal 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
60
src/hierarchy/roles.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const Role = z.enum(["manager", "principal", "lead", "junior"]);
|
||||||
|
export type Role = z.infer<typeof Role>;
|
||||||
|
|
||||||
|
export const ROLE_KOREAN: Record<Role, string> = {
|
||||||
|
manager: "부장",
|
||||||
|
principal: "수석",
|
||||||
|
lead: "선임",
|
||||||
|
junior: "신입",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface RoleConfig {
|
||||||
|
primaryModel: string;
|
||||||
|
fallbackModel: string;
|
||||||
|
canSpawn: Role[];
|
||||||
|
maxSpawnPerCall: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default role definitions. Can be overridden by roles.yaml in sister-agent.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_ROLE_CONFIG: Record<Role, RoleConfig> = {
|
||||||
|
manager: {
|
||||||
|
primaryModel: "gpt-5.4",
|
||||||
|
fallbackModel: "glm-5.1",
|
||||||
|
canSpawn: ["principal", "lead", "junior"],
|
||||||
|
maxSpawnPerCall: 4,
|
||||||
|
},
|
||||||
|
principal: {
|
||||||
|
primaryModel: "gpt-5.4",
|
||||||
|
fallbackModel: "glm-5.1",
|
||||||
|
canSpawn: ["lead", "junior"],
|
||||||
|
maxSpawnPerCall: 3,
|
||||||
|
},
|
||||||
|
lead: {
|
||||||
|
primaryModel: "gpt-codex-5.3",
|
||||||
|
fallbackModel: "glm-5",
|
||||||
|
canSpawn: ["junior"],
|
||||||
|
maxSpawnPerCall: 4,
|
||||||
|
},
|
||||||
|
junior: {
|
||||||
|
primaryModel: "glm-5-turbo",
|
||||||
|
fallbackModel: "gpt-5",
|
||||||
|
canSpawn: [],
|
||||||
|
maxSpawnPerCall: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ConcurrencyLimits {
|
||||||
|
default: number;
|
||||||
|
overrides: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_CONCURRENCY: ConcurrencyLimits = {
|
||||||
|
default: 8,
|
||||||
|
overrides: {
|
||||||
|
narang: 6, // tighter when a build is running
|
||||||
|
},
|
||||||
|
};
|
||||||
234
src/hierarchy/store.ts
Normal file
234
src/hierarchy/store.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { getPrisma } from "../orchestrator/persist.js";
|
||||||
|
import { Role } from "./roles.js";
|
||||||
|
import { ComplexityTier } from "./complexity.js";
|
||||||
|
import { childLogger } from "../logger.js";
|
||||||
|
|
||||||
|
const log = childLogger({ module: "sub-task-store" });
|
||||||
|
|
||||||
|
export const CreateSubTaskInput = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
pipelineId: z.string().min(1),
|
||||||
|
parentId: z.string().nullable().default(null),
|
||||||
|
role: Role,
|
||||||
|
agentName: z.string().min(1),
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string().default(""),
|
||||||
|
complexityScore: z.number().int().nullable().default(null),
|
||||||
|
complexityTier: ComplexityTier.nullable().default(null),
|
||||||
|
model: z.string().default(""),
|
||||||
|
});
|
||||||
|
export type CreateSubTaskInput = z.infer<typeof CreateSubTaskInput>;
|
||||||
|
|
||||||
|
export const SubTaskEventInput = z.object({
|
||||||
|
subTaskId: z.string().min(1),
|
||||||
|
eventType: z.enum([
|
||||||
|
"spawned",
|
||||||
|
"started",
|
||||||
|
"progress",
|
||||||
|
"output",
|
||||||
|
"completed",
|
||||||
|
"failed",
|
||||||
|
"escalated",
|
||||||
|
]),
|
||||||
|
payload: z.record(z.unknown()).default({}),
|
||||||
|
});
|
||||||
|
export type SubTaskEventInput = z.infer<typeof SubTaskEventInput>;
|
||||||
|
|
||||||
|
export const UpdateSubTaskInput = z.object({
|
||||||
|
state: z
|
||||||
|
.enum(["queued", "running", "done", "failed", "escalated"])
|
||||||
|
.optional(),
|
||||||
|
resultJson: z.string().optional(),
|
||||||
|
errorReason: z.string().optional(),
|
||||||
|
startedAt: z.string().datetime().optional(),
|
||||||
|
completedAt: z.string().datetime().optional(),
|
||||||
|
});
|
||||||
|
export type UpdateSubTaskInput = z.infer<typeof UpdateSubTaskInput>;
|
||||||
|
|
||||||
|
export async function createSubTask(input: CreateSubTaskInput): Promise<void> {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
await prisma.subTask.create({
|
||||||
|
data: {
|
||||||
|
id: input.id,
|
||||||
|
pipelineId: input.pipelineId,
|
||||||
|
parentId: input.parentId,
|
||||||
|
role: input.role,
|
||||||
|
agentName: input.agentName,
|
||||||
|
title: input.title.slice(0, 500),
|
||||||
|
description: input.description,
|
||||||
|
state: "queued",
|
||||||
|
complexityScore: input.complexityScore,
|
||||||
|
complexityTier: input.complexityTier,
|
||||||
|
model: input.model,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
log.info(
|
||||||
|
{
|
||||||
|
id: input.id,
|
||||||
|
role: input.role,
|
||||||
|
agent: input.agentName,
|
||||||
|
parent: input.parentId,
|
||||||
|
},
|
||||||
|
"Sub-task created",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSubTask(
|
||||||
|
id: string,
|
||||||
|
patch: UpdateSubTaskInput,
|
||||||
|
): Promise<void> {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
await prisma.subTask.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...patch,
|
||||||
|
startedAt: patch.startedAt ? new Date(patch.startedAt) : undefined,
|
||||||
|
completedAt: patch.completedAt ? new Date(patch.completedAt) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordSubTaskEvent(
|
||||||
|
input: SubTaskEventInput,
|
||||||
|
): Promise<void> {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
await prisma.subTaskEvent.create({
|
||||||
|
data: {
|
||||||
|
subTaskId: input.subTaskId,
|
||||||
|
eventType: input.eventType,
|
||||||
|
payloadJson: JSON.stringify(input.payload),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-advance state based on event type
|
||||||
|
const stateMap: Record<string, string | null> = {
|
||||||
|
started: "running",
|
||||||
|
completed: "done",
|
||||||
|
failed: "failed",
|
||||||
|
escalated: "escalated",
|
||||||
|
};
|
||||||
|
const newState = stateMap[input.eventType];
|
||||||
|
if (newState) {
|
||||||
|
const patch: UpdateSubTaskInput = { state: newState as UpdateSubTaskInput["state"] };
|
||||||
|
if (input.eventType === "started") {
|
||||||
|
patch.startedAt = new Date().toISOString();
|
||||||
|
} else if (["completed", "failed", "escalated"].includes(input.eventType)) {
|
||||||
|
patch.completedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
await prisma.subTask.update({
|
||||||
|
where: { id: input.subTaskId },
|
||||||
|
data: {
|
||||||
|
...patch,
|
||||||
|
startedAt: patch.startedAt ? new Date(patch.startedAt) : undefined,
|
||||||
|
completedAt: patch.completedAt ? new Date(patch.completedAt) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseJson(s: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(s);
|
||||||
|
} catch {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSubTaskDetail(id: string): Promise<unknown | null> {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
const node = await prisma.subTask.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
events: {
|
||||||
|
orderBy: { timestamp: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
eventType: true,
|
||||||
|
payloadJson: true,
|
||||||
|
timestamp: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!node) return null;
|
||||||
|
|
||||||
|
// Walk up parent chain
|
||||||
|
const parents: Array<{ id: string; role: string; title: string }> = [];
|
||||||
|
let cursor: string | null = node.parentId;
|
||||||
|
while (cursor) {
|
||||||
|
const p = await prisma.subTask.findUnique({
|
||||||
|
where: { id: cursor },
|
||||||
|
select: { id: true, parentId: true, role: true, title: true },
|
||||||
|
});
|
||||||
|
if (!p) break;
|
||||||
|
parents.unshift({ id: p.id, role: p.role, title: p.title });
|
||||||
|
cursor = p.parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct children list
|
||||||
|
const children = await prisma.subTask.findMany({
|
||||||
|
where: { parentId: id },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
role: true,
|
||||||
|
agentName: true,
|
||||||
|
title: true,
|
||||||
|
state: true,
|
||||||
|
model: true,
|
||||||
|
startedAt: true,
|
||||||
|
completedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
parents,
|
||||||
|
childrenList: children,
|
||||||
|
events: node.events.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
eventType: e.eventType,
|
||||||
|
payload: tryParseJson(e.payloadJson),
|
||||||
|
timestamp: e.timestamp,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSubTaskTree(pipelineId: string): Promise<unknown> {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
const all = await prisma.subTask.findMany({
|
||||||
|
where: { pipelineId },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
parentId: true,
|
||||||
|
role: true,
|
||||||
|
agentName: true,
|
||||||
|
title: true,
|
||||||
|
state: true,
|
||||||
|
complexityScore: true,
|
||||||
|
complexityTier: true,
|
||||||
|
model: true,
|
||||||
|
startedAt: true,
|
||||||
|
completedAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build tree
|
||||||
|
const byId = new Map<string, { id: string; parentId: string | null; children: unknown[] } & Record<string, unknown>>();
|
||||||
|
for (const t of all) {
|
||||||
|
byId.set(t.id, { ...t, children: [] });
|
||||||
|
}
|
||||||
|
const roots: unknown[] = [];
|
||||||
|
for (const t of all) {
|
||||||
|
const node = byId.get(t.id)!;
|
||||||
|
if (t.parentId && byId.has(t.parentId)) {
|
||||||
|
(byId.get(t.parentId)!.children as unknown[]).push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
@@ -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,97 @@ 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 listTransitions(opts?: {
|
||||||
|
pipelineId?: string;
|
||||||
|
eventType?: string;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<
|
||||||
|
Array<{
|
||||||
|
id: number;
|
||||||
|
pipelineId: string;
|
||||||
|
fromState: string;
|
||||||
|
toState: string;
|
||||||
|
eventType: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
const where: { pipelineId?: string; eventType?: string } = {};
|
||||||
|
if (opts?.pipelineId) where.pipelineId = opts.pipelineId;
|
||||||
|
if (opts?.eventType) where.eventType = opts.eventType;
|
||||||
|
|
||||||
|
return prisma.stateTransition.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { timestamp: "desc" },
|
||||||
|
take: opts?.limit ?? 100,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
pipelineId: true,
|
||||||
|
fromState: true,
|
||||||
|
toState: true,
|
||||||
|
eventType: true,
|
||||||
|
timestamp: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEscalations(opts?: {
|
||||||
|
pipelineId?: string;
|
||||||
|
resolved?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
pipelineId: string;
|
||||||
|
reason: string;
|
||||||
|
errorCategory: string;
|
||||||
|
stage: string;
|
||||||
|
attempts: number;
|
||||||
|
contextSnapshot: string;
|
||||||
|
resolvedAt: Date | null;
|
||||||
|
resolution: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
const where: { pipelineId?: string; resolvedAt?: null | { not: null } } = {};
|
||||||
|
if (opts?.pipelineId) where.pipelineId = opts.pipelineId;
|
||||||
|
if (opts?.resolved === false) where.resolvedAt = null;
|
||||||
|
if (opts?.resolved === true) where.resolvedAt = { not: null };
|
||||||
|
|
||||||
|
return prisma.escalation.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: opts?.limit ?? 50,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function listPipelines(opts?: {
|
export async function listPipelines(opts?: {
|
||||||
state?: PipelineState;
|
state?: PipelineState;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|||||||
237
src/orchestrator/runner.ts
Normal file
237
src/orchestrator/runner.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { ulid } from "ulid";
|
||||||
|
import { sendEvent, createPipeline } from "./persist.js";
|
||||||
|
import type { PipelineEvent, PipelineState } from "./events.js";
|
||||||
|
import type { HandoffMessage, InvokeRequest } from "../handoff/message.js";
|
||||||
|
import type { SisterTransport } from "../handoff/transport.js";
|
||||||
|
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";
|
||||||
|
|
||||||
|
const log = childLogger({ module: "runner" });
|
||||||
|
|
||||||
|
export interface RunOptions {
|
||||||
|
projectName: string;
|
||||||
|
requirements: string;
|
||||||
|
config: RailsConfig;
|
||||||
|
transports: Map<string, SisterTransport>;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
maxRetries?: number;
|
||||||
|
notifier?: EscalationNotifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunResult {
|
||||||
|
pipelineId: string;
|
||||||
|
finalState: PipelineState;
|
||||||
|
transitions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run an end-to-end pipeline using the configured transports.
|
||||||
|
* Each stage invokes the corresponding agent and feeds the result back
|
||||||
|
* into the FSM until done or escalated.
|
||||||
|
*/
|
||||||
|
export async function runPipeline(opts: RunOptions): Promise<RunResult> {
|
||||||
|
const { pipelineId, state: initialState } = await createPipeline(
|
||||||
|
opts.projectName,
|
||||||
|
opts.requirements,
|
||||||
|
);
|
||||||
|
|
||||||
|
log.info({ pipelineId, project: opts.projectName }, "Pipeline run started");
|
||||||
|
|
||||||
|
// REQUEST event — enters planning
|
||||||
|
let result = await sendEvent(pipelineId, {
|
||||||
|
type: "REQUEST",
|
||||||
|
projectName: opts.projectName,
|
||||||
|
requirements: opts.requirements,
|
||||||
|
});
|
||||||
|
|
||||||
|
let transitions = 1;
|
||||||
|
const TERMINAL: PipelineState[] = ["done", "escalated", "aborted"];
|
||||||
|
|
||||||
|
while (!TERMINAL.includes(result.state)) {
|
||||||
|
if (opts.signal?.aborted) {
|
||||||
|
result = await sendEvent(pipelineId, {
|
||||||
|
type: "ABORT",
|
||||||
|
reason: "Aborted by caller",
|
||||||
|
});
|
||||||
|
transitions += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stage = mapStateToStage(result.state);
|
||||||
|
if (!stage) {
|
||||||
|
log.warn({ state: result.state }, "Non-active state encountered, stopping");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transport = opts.transports.get(stage);
|
||||||
|
if (!transport) {
|
||||||
|
log.error({ stage }, "No transport configured for stage");
|
||||||
|
result = await sendEvent(pipelineId, {
|
||||||
|
type: "ERROR",
|
||||||
|
actor: stage,
|
||||||
|
reason: `No transport configured for stage: ${stage}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
transitions += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invokeReq: InvokeRequest = {
|
||||||
|
pipelineId,
|
||||||
|
contractId: result.context.contractPath ?? "",
|
||||||
|
stage,
|
||||||
|
role: opts.config.agents[stage]?.role ?? stage,
|
||||||
|
sprintId: result.context.currentSprintId ?? "",
|
||||||
|
task: {
|
||||||
|
title: opts.requirements || opts.projectName,
|
||||||
|
description: opts.requirements,
|
||||||
|
workdir: process.cwd(),
|
||||||
|
},
|
||||||
|
timeoutMs: opts.config.agents[stage]?.timeoutMs ?? 600_000,
|
||||||
|
structuredOutput: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryResult = await withRetry(
|
||||||
|
async () => transport.invoke(invokeReq, opts.signal),
|
||||||
|
{
|
||||||
|
maxRetries: opts.maxRetries ?? 1,
|
||||||
|
...(opts.signal && { signal: opts.signal }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (retryResult.ok && retryResult.value) {
|
||||||
|
const event = handoffToEvent(retryResult.value);
|
||||||
|
result = await sendEvent(pipelineId, event);
|
||||||
|
transitions += 1;
|
||||||
|
} else {
|
||||||
|
const classification = retryResult.classification;
|
||||||
|
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, {
|
||||||
|
type: "ERROR",
|
||||||
|
actor: stage,
|
||||||
|
reason,
|
||||||
|
retryable: classification?.retryable ?? false,
|
||||||
|
});
|
||||||
|
transitions += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{ pipelineId, finalState: result.state, transitions },
|
||||||
|
"Pipeline run finished",
|
||||||
|
);
|
||||||
|
|
||||||
|
void initialState; // referenced only for typecheck
|
||||||
|
return {
|
||||||
|
pipelineId,
|
||||||
|
finalState: result.state,
|
||||||
|
transitions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStateToStage(
|
||||||
|
state: PipelineState,
|
||||||
|
): "plan" | "implement" | "review" | "deploy" | null {
|
||||||
|
switch (state) {
|
||||||
|
case "planning":
|
||||||
|
return "plan";
|
||||||
|
case "implementing":
|
||||||
|
return "implement";
|
||||||
|
case "reviewing":
|
||||||
|
return "review";
|
||||||
|
case "deploying":
|
||||||
|
return "deploy";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handoffToEvent(h: HandoffMessage): PipelineEvent {
|
||||||
|
switch (h.stage) {
|
||||||
|
case "plan":
|
||||||
|
if (h.verdict === "PLAN_READY" && h.payload) {
|
||||||
|
return {
|
||||||
|
type: "PLAN_READY",
|
||||||
|
planDir: h.payload.planDir,
|
||||||
|
sprintId: h.payload.sprintId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "ABORT",
|
||||||
|
reason: h.abortReason || "Planner aborted",
|
||||||
|
};
|
||||||
|
case "implement":
|
||||||
|
if (h.verdict === "IMPL_DONE" && h.payload) {
|
||||||
|
return {
|
||||||
|
type: "IMPL_DONE",
|
||||||
|
branch: h.payload.branch,
|
||||||
|
commits: h.payload.commits,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "ERROR",
|
||||||
|
actor: "implement",
|
||||||
|
reason: h.errorReason || "Implementation failed",
|
||||||
|
retryable: true,
|
||||||
|
};
|
||||||
|
case "review":
|
||||||
|
if (h.verdict === "APPROVE" && h.payload) {
|
||||||
|
return { type: "APPROVE", reviewArtifact: h.payload.artifactPath };
|
||||||
|
}
|
||||||
|
if (h.verdict === "REQUEST_CHANGES" && h.payload) {
|
||||||
|
return {
|
||||||
|
type: "REQUEST_CHANGES",
|
||||||
|
issues: h.payload.issues.map((i) => ({
|
||||||
|
severity: i.severity,
|
||||||
|
message: i.message,
|
||||||
|
...(i.file !== undefined && { file: i.file }),
|
||||||
|
...(i.line !== undefined && { line: i.line }),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { type: "ABORT", reason: h.abortReason || "Review aborted" };
|
||||||
|
case "deploy":
|
||||||
|
if (h.verdict === "DEPLOY_DONE" && h.payload) {
|
||||||
|
return {
|
||||||
|
type: "DEPLOY_DONE",
|
||||||
|
deployArtifact: h.payload.deployArtifactPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "ERROR",
|
||||||
|
actor: "deploy",
|
||||||
|
reason: h.errorReason || "Deploy failed",
|
||||||
|
retryable: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ulid; // satisfy unused import check if any
|
||||||
191
src/qa/runtime.ts
Normal file
191
src/qa/runtime.ts
Normal 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
61
src/qa/schema.ts
Normal 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
158
src/qa/template.ts
Normal 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
60
src/qa/verdict.ts
Normal 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
46
src/resilience/backoff.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
126
src/resilience/classifier.ts
Normal file
126
src/resilience/classifier.ts
Normal 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
137
src/resilience/escalate.ts
Normal 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
97
src/resilience/kill.ts
Normal 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
108
src/resilience/retry.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
284
src/server/http.ts
Normal file
284
src/server/http.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
createPipeline,
|
||||||
|
getPipelineState,
|
||||||
|
listPipelines,
|
||||||
|
listTransitions,
|
||||||
|
listEscalations,
|
||||||
|
sendEvent,
|
||||||
|
} from "../orchestrator/persist.js";
|
||||||
|
import { runPipeline } 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,
|
||||||
|
getSubTaskDetail,
|
||||||
|
} from "../hierarchy/store.js";
|
||||||
|
import { childLogger } from "../logger.js";
|
||||||
|
|
||||||
|
const log = childLogger({ module: "http-server" });
|
||||||
|
|
||||||
|
const StartRequest = z.object({
|
||||||
|
project: z.string().min(1),
|
||||||
|
requirements: z.string().default(""),
|
||||||
|
mock: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
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 } = parsed.data;
|
||||||
|
|
||||||
|
// Run pipeline (async, but we await for this simple demo)
|
||||||
|
// Transport is determined by env RAILS_TRANSPORT_MODE and rails.config.yaml
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Single sub-task detail ──
|
||||||
|
const detailMatch = path.match(/^\/api\/sub-tasks\/([^/]+)$/);
|
||||||
|
if (method === "GET" && detailMatch) {
|
||||||
|
const id = detailMatch[1]!;
|
||||||
|
const detail = await getSubTaskDetail(id);
|
||||||
|
if (!detail) return sendJson(res, 404, { error: "not_found" });
|
||||||
|
return sendJson(res, 200, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State transitions (SIEM-style log) ──
|
||||||
|
if (method === "GET" && path === "/api/transitions") {
|
||||||
|
const limit = parseInt(url.searchParams.get("limit") ?? "100", 10);
|
||||||
|
const pid = url.searchParams.get("pipelineId") ?? undefined;
|
||||||
|
const eventType = url.searchParams.get("eventType") ?? undefined;
|
||||||
|
const transitions = await listTransitions({
|
||||||
|
...(pid !== undefined && { pipelineId: pid }),
|
||||||
|
...(eventType !== undefined && { eventType }),
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
return sendJson(res, 200, { transitions });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Escalations ──
|
||||||
|
if (method === "GET" && path === "/api/escalations") {
|
||||||
|
const limit = parseInt(url.searchParams.get("limit") ?? "50", 10);
|
||||||
|
const pid = url.searchParams.get("pipelineId") ?? undefined;
|
||||||
|
const resolvedQ = url.searchParams.get("resolved");
|
||||||
|
const opts: { pipelineId?: string; resolved?: boolean; limit: number } = { limit };
|
||||||
|
if (pid !== undefined) opts.pipelineId = pid;
|
||||||
|
if (resolvedQ === "true") opts.resolved = true;
|
||||||
|
else if (resolvedQ === "false") opts.resolved = false;
|
||||||
|
const escalations = await listEscalations(opts);
|
||||||
|
return sendJson(res, 200, { escalations });
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJson(res, 404, { error: "not_found", path });
|
||||||
|
} catch (err) {
|
||||||
|
log.error(
|
||||||
|
{ err: err instanceof Error ? err.message : String(err) },
|
||||||
|
"Request handler error",
|
||||||
|
);
|
||||||
|
return sendJson(res, 500, {
|
||||||
|
error: "internal_error",
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolveFn) => {
|
||||||
|
server.listen(opts.port, host, () => resolveFn());
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${opts.port}`;
|
||||||
|
log.info({ url }, "HTTP server listening");
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
async close() {
|
||||||
|
await new Promise<void>((resolveFn, rejectFn) => {
|
||||||
|
server.close((err) => (err ? rejectFn(err) : resolveFn()));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(req: IncomingMessage): Promise<unknown> {
|
||||||
|
return new Promise((resolveFn, rejectFn) => {
|
||||||
|
let body = "";
|
||||||
|
req.on("data", (chunk: Buffer) => (body += chunk.toString()));
|
||||||
|
req.on("end", () => {
|
||||||
|
if (!body) return resolveFn({});
|
||||||
|
try {
|
||||||
|
resolveFn(JSON.parse(body));
|
||||||
|
} catch (err) {
|
||||||
|
rejectFn(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on("error", rejectFn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||||
|
res.writeHead(status, {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"cache-control": "no-store",
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
79
tests/config.test.ts
Normal file
79
tests/config.test.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { loadConfig } from "../src/config/loader.js";
|
||||||
|
import { DEFAULT_CONFIG } from "../src/config/schema.js";
|
||||||
|
|
||||||
|
let testDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
testDir = await mkdtemp(join(tmpdir(), "rails-config-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(testDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadConfig", () => {
|
||||||
|
it("returns defaults when no path provided", async () => {
|
||||||
|
const cfg = await loadConfig();
|
||||||
|
expect(cfg).toEqual(DEFAULT_CONFIG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns defaults when file does not exist", async () => {
|
||||||
|
const cfg = await loadConfig(join(testDir, "missing.yaml"));
|
||||||
|
expect(cfg).toEqual(DEFAULT_CONFIG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses valid yaml config", async () => {
|
||||||
|
const yamlPath = join(testDir, "rails.config.yaml");
|
||||||
|
await writeFile(
|
||||||
|
yamlPath,
|
||||||
|
`
|
||||||
|
pipeline:
|
||||||
|
stages: [plan, implement, review]
|
||||||
|
agents:
|
||||||
|
plan:
|
||||||
|
role: plan
|
||||||
|
displayName: TestPlanner
|
||||||
|
transport: mock
|
||||||
|
timeoutMs: 15000
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const cfg = await loadConfig(yamlPath);
|
||||||
|
expect(cfg.pipeline.stages).toEqual(["plan", "implement", "review"]);
|
||||||
|
expect(cfg.agents["plan"]?.displayName).toBe("TestPlanner");
|
||||||
|
expect(cfg.agents["plan"]?.timeoutMs).toBe(15_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("interpolates environment variables", async () => {
|
||||||
|
const yamlPath = join(testDir, "rails.config.yaml");
|
||||||
|
await writeFile(
|
||||||
|
yamlPath,
|
||||||
|
`
|
||||||
|
discord:
|
||||||
|
enabled: true
|
||||||
|
railsToken: \${MY_TEST_TOKEN}
|
||||||
|
guildId: fixed-guild
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const cfg = await loadConfig(yamlPath, { MY_TEST_TOKEN: "secret-abc" });
|
||||||
|
expect(cfg.discord.railsToken).toBe("secret-abc");
|
||||||
|
expect(cfg.discord.guildId).toBe("fixed-guild");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults missing env vars to empty string", async () => {
|
||||||
|
const yamlPath = join(testDir, "rails.config.yaml");
|
||||||
|
await writeFile(
|
||||||
|
yamlPath,
|
||||||
|
`
|
||||||
|
discord:
|
||||||
|
enabled: false
|
||||||
|
railsToken: \${MISSING_VAR}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const cfg = await loadConfig(yamlPath, {});
|
||||||
|
expect(cfg.discord.railsToken).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
447
tests/contract.test.ts
Normal file
447
tests/contract.test.ts
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
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 { createServer, type Server } from "node:http";
|
||||||
|
import { validateContract } from "../src/contract/validator.js";
|
||||||
|
import { fileExistsCheck } from "../src/contract/checks/file-exists.js";
|
||||||
|
import { commandSuccessCheck } from "../src/contract/checks/command-success.js";
|
||||||
|
import {
|
||||||
|
regexInFileCheck,
|
||||||
|
regexAbsentCheck,
|
||||||
|
} from "../src/contract/checks/regex-in-file.js";
|
||||||
|
import { httpStatusCheck } from "../src/contract/checks/http-status.js";
|
||||||
|
import { artifactSchemaCheck } from "../src/contract/checks/artifact-schema.js";
|
||||||
|
import { manualCheck } from "../src/contract/checks/manual.js";
|
||||||
|
import { generateDraftContract } from "../src/contract/generator.js";
|
||||||
|
import { saveDraftContract, loadContract } from "../src/contract/store.js";
|
||||||
|
|
||||||
|
let testDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
testDir = await mkdtemp(join(tmpdir(), "rails-contract-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(testDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("file_exists check", () => {
|
||||||
|
it("passes when file exists", async () => {
|
||||||
|
await writeFile(join(testDir, "README.md"), "# test");
|
||||||
|
const result = await fileExistsCheck(
|
||||||
|
{
|
||||||
|
id: "readme",
|
||||||
|
description: "",
|
||||||
|
kind: "file_exists",
|
||||||
|
spec: { path: "README.md" },
|
||||||
|
blocking: true,
|
||||||
|
severity: "major",
|
||||||
|
},
|
||||||
|
{ workdir: testDir, env: {} },
|
||||||
|
);
|
||||||
|
expect(result.passed).toBe(true);
|
||||||
|
expect(result.evidence).toContain("exists");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when file missing", async () => {
|
||||||
|
const result = await fileExistsCheck(
|
||||||
|
{
|
||||||
|
id: "nope",
|
||||||
|
description: "",
|
||||||
|
kind: "file_exists",
|
||||||
|
spec: { path: "missing.txt" },
|
||||||
|
blocking: true,
|
||||||
|
severity: "major",
|
||||||
|
},
|
||||||
|
{ workdir: testDir, env: {} },
|
||||||
|
);
|
||||||
|
expect(result.passed).toBe(false);
|
||||||
|
expect(result.errorMessage).toContain("not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("command_success check", () => {
|
||||||
|
it("passes on exit 0", async () => {
|
||||||
|
const result = await commandSuccessCheck(
|
||||||
|
{
|
||||||
|
id: "true",
|
||||||
|
description: "",
|
||||||
|
kind: "command_success",
|
||||||
|
spec: { command: "true", timeoutMs: 5000, expectExitCode: 0 },
|
||||||
|
blocking: true,
|
||||||
|
severity: "major",
|
||||||
|
},
|
||||||
|
{ workdir: testDir, env: process.env as Record<string, string> },
|
||||||
|
);
|
||||||
|
expect(result.passed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails on non-zero exit", async () => {
|
||||||
|
const result = await commandSuccessCheck(
|
||||||
|
{
|
||||||
|
id: "false",
|
||||||
|
description: "",
|
||||||
|
kind: "command_success",
|
||||||
|
spec: { command: "false", timeoutMs: 5000, expectExitCode: 0 },
|
||||||
|
blocking: true,
|
||||||
|
severity: "major",
|
||||||
|
},
|
||||||
|
{ workdir: testDir, env: process.env as Record<string, string> },
|
||||||
|
);
|
||||||
|
expect(result.passed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails on timeout", async () => {
|
||||||
|
const result = await commandSuccessCheck(
|
||||||
|
{
|
||||||
|
id: "sleep",
|
||||||
|
description: "",
|
||||||
|
kind: "command_success",
|
||||||
|
spec: { command: "sleep 5", timeoutMs: 300, expectExitCode: 0 },
|
||||||
|
blocking: true,
|
||||||
|
severity: "major",
|
||||||
|
},
|
||||||
|
{ workdir: testDir, env: process.env as Record<string, string> },
|
||||||
|
);
|
||||||
|
expect(result.passed).toBe(false);
|
||||||
|
expect(result.errorMessage).toContain("timed out");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("regex_in_file check", () => {
|
||||||
|
it("matches pattern", async () => {
|
||||||
|
await writeFile(join(testDir, "config.json"), '{"strict": true}');
|
||||||
|
const result = await regexInFileCheck(
|
||||||
|
{
|
||||||
|
id: "strict",
|
||||||
|
description: "",
|
||||||
|
kind: "regex_in_file",
|
||||||
|
spec: { path: "config.json", pattern: '"strict"\\s*:\\s*true' },
|
||||||
|
blocking: true,
|
||||||
|
severity: "major",
|
||||||
|
},
|
||||||
|
{ workdir: testDir, env: {} },
|
||||||
|
);
|
||||||
|
expect(result.passed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when pattern absent", async () => {
|
||||||
|
await writeFile(join(testDir, "config.json"), "{}");
|
||||||
|
const result = await regexInFileCheck(
|
||||||
|
{
|
||||||
|
id: "strict",
|
||||||
|
description: "",
|
||||||
|
kind: "regex_in_file",
|
||||||
|
spec: { path: "config.json", pattern: "strict" },
|
||||||
|
blocking: true,
|
||||||
|
severity: "major",
|
||||||
|
},
|
||||||
|
{ workdir: testDir, env: {} },
|
||||||
|
);
|
||||||
|
expect(result.passed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("regex_absent check", () => {
|
||||||
|
it("passes when pattern absent", async () => {
|
||||||
|
await writeFile(join(testDir, "code.ts"), "const x = 1");
|
||||||
|
const result = await regexAbsentCheck(
|
||||||
|
{
|
||||||
|
id: "no-console",
|
||||||
|
description: "",
|
||||||
|
kind: "regex_absent",
|
||||||
|
spec: { path: "code.ts", pattern: "console\\." },
|
||||||
|
blocking: true,
|
||||||
|
severity: "major",
|
||||||
|
},
|
||||||
|
{ workdir: testDir, env: {} },
|
||||||
|
);
|
||||||
|
expect(result.passed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when forbidden pattern found", async () => {
|
||||||
|
await writeFile(join(testDir, "code.ts"), "console.log(42)");
|
||||||
|
const result = await regexAbsentCheck(
|
||||||
|
{
|
||||||
|
id: "no-console",
|
||||||
|
description: "",
|
||||||
|
kind: "regex_absent",
|
||||||
|
spec: { path: "code.ts", pattern: "console\\." },
|
||||||
|
blocking: true,
|
||||||
|
severity: "major",
|
||||||
|
},
|
||||||
|
{ workdir: testDir, env: {} },
|
||||||
|
);
|
||||||
|
expect(result.passed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("http_status check", () => {
|
||||||
|
let server: Server;
|
||||||
|
let port: number;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
server = createServer((req, res) => {
|
||||||
|
if (req.url === "/ok") {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end("ok");
|
||||||
|
} else if (req.url === "/notfound") {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
} else {
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
||||||
|
const addr = server.address();
|
||||||
|
if (typeof addr === "object" && addr) {
|
||||||
|
port = addr.port;
|
||||||
|
} else {
|
||||||
|
throw new Error("Cannot get server port");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await new Promise<void>((r) => server.close(() => r()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes on matching status", async () => {
|
||||||
|
const result = await httpStatusCheck(
|
||||||
|
{
|
||||||
|
id: "health",
|
||||||
|
description: "",
|
||||||
|
kind: "http_status",
|
||||||
|
spec: {
|
||||||
|
url: `http://127.0.0.1:${port}/ok`,
|
||||||
|
expectStatus: 200,
|
||||||
|
},
|
||||||
|
blocking: true,
|
||||||
|
severity: "major",
|
||||||
|
},
|
||||||
|
{ workdir: testDir, env: {} },
|
||||||
|
);
|
||||||
|
expect(result.passed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails on status mismatch", async () => {
|
||||||
|
const result = await httpStatusCheck(
|
||||||
|
{
|
||||||
|
id: "health",
|
||||||
|
description: "",
|
||||||
|
kind: "http_status",
|
||||||
|
spec: {
|
||||||
|
url: `http://127.0.0.1:${port}/notfound`,
|
||||||
|
expectStatus: 200,
|
||||||
|
},
|
||||||
|
blocking: true,
|
||||||
|
severity: "major",
|
||||||
|
},
|
||||||
|
{ workdir: testDir, env: {} },
|
||||||
|
);
|
||||||
|
expect(result.passed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("artifact_schema check", () => {
|
||||||
|
it("passes when JSON matches registered schema", async () => {
|
||||||
|
const valid = {
|
||||||
|
id: "c1",
|
||||||
|
kind: "file_exists",
|
||||||
|
passed: true,
|
||||||
|
blocking: true,
|
||||||
|
severity: "major",
|
||||||
|
evidence: "found",
|
||||||
|
errorMessage: "",
|
||||||
|
durationMs: 5,
|
||||||
|
};
|
||||||
|
await writeFile(join(testDir, "result.json"), JSON.stringify(valid));
|
||||||
|
const result = await artifactSchemaCheck(
|
||||||
|
{
|
||||||
|
id: "schema",
|
||||||
|
description: "",
|
||||||
|
kind: "artifact_schema",
|
||||||
|
spec: { artifactPath: "result.json", schemaName: "CheckResult" },
|
||||||
|
blocking: true,
|
||||||
|
severity: "major",
|
||||||
|
},
|
||||||
|
{ workdir: testDir, env: {} },
|
||||||
|
);
|
||||||
|
expect(result.passed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when schema name is unknown", async () => {
|
||||||
|
const result = await artifactSchemaCheck(
|
||||||
|
{
|
||||||
|
id: "schema",
|
||||||
|
description: "",
|
||||||
|
kind: "artifact_schema",
|
||||||
|
spec: { artifactPath: "nope.json", schemaName: "NonExistent" },
|
||||||
|
blocking: true,
|
||||||
|
severity: "major",
|
||||||
|
},
|
||||||
|
{ workdir: testDir, env: {} },
|
||||||
|
);
|
||||||
|
expect(result.passed).toBe(false);
|
||||||
|
expect(result.errorMessage).toContain("Unknown schema");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("manual check (stub)", () => {
|
||||||
|
it("is always SKIP (passed=true) in Sprint 003", async () => {
|
||||||
|
const result = await manualCheck(
|
||||||
|
{
|
||||||
|
id: "review",
|
||||||
|
description: "",
|
||||||
|
kind: "manual",
|
||||||
|
spec: { question: "Is the code clean?" },
|
||||||
|
blocking: true,
|
||||||
|
severity: "major",
|
||||||
|
},
|
||||||
|
{ workdir: testDir, env: {} },
|
||||||
|
);
|
||||||
|
expect(result.passed).toBe(true);
|
||||||
|
expect(result.evidence).toContain("SKIPPED");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validator integration", () => {
|
||||||
|
it("PASS when all checks pass", async () => {
|
||||||
|
await writeFile(join(testDir, "README.md"), "# ok");
|
||||||
|
const contract = {
|
||||||
|
version: "v1" as const,
|
||||||
|
id: "c1",
|
||||||
|
sprintId: "S1",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
type: "feature" as const,
|
||||||
|
dod: {
|
||||||
|
checks: [
|
||||||
|
{
|
||||||
|
id: "readme",
|
||||||
|
description: "",
|
||||||
|
kind: "file_exists" as const,
|
||||||
|
spec: { path: "README.md" },
|
||||||
|
blocking: true,
|
||||||
|
severity: "major" as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
environmentPrerequisites: [],
|
||||||
|
nonGoals: [],
|
||||||
|
runtimeValidation: { commands: [] },
|
||||||
|
riskFlags: [],
|
||||||
|
reviewerProfile: "static" as const,
|
||||||
|
approvalGates: { impl: true, review: true, deploy: true },
|
||||||
|
};
|
||||||
|
const result = await validateContract(contract, { workdir: testDir });
|
||||||
|
expect(result.verdict).toBe("PASS");
|
||||||
|
expect(result.summary.passed).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FAIL when a blocking check fails", async () => {
|
||||||
|
const contract = {
|
||||||
|
version: "v1" as const,
|
||||||
|
id: "c2",
|
||||||
|
sprintId: "S1",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
type: "feature" as const,
|
||||||
|
dod: {
|
||||||
|
checks: [
|
||||||
|
{
|
||||||
|
id: "missing",
|
||||||
|
description: "",
|
||||||
|
kind: "file_exists" as const,
|
||||||
|
spec: { path: "does-not-exist.txt" },
|
||||||
|
blocking: true,
|
||||||
|
severity: "major" as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
environmentPrerequisites: [],
|
||||||
|
nonGoals: [],
|
||||||
|
runtimeValidation: { commands: [] },
|
||||||
|
riskFlags: [],
|
||||||
|
reviewerProfile: "static" as const,
|
||||||
|
approvalGates: { impl: true, review: true, deploy: true },
|
||||||
|
};
|
||||||
|
const result = await validateContract(contract, { workdir: testDir });
|
||||||
|
expect(result.verdict).toBe("FAIL");
|
||||||
|
expect(result.summary.blockingFailed).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ABORT_PRECHECK when prerequisite missing", async () => {
|
||||||
|
const contract = {
|
||||||
|
version: "v1" as const,
|
||||||
|
id: "c3",
|
||||||
|
sprintId: "S1",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
type: "feature" as const,
|
||||||
|
dod: { checks: [] },
|
||||||
|
environmentPrerequisites: [
|
||||||
|
{
|
||||||
|
name: "nonexistent-cmd",
|
||||||
|
check: "command_exists" as const,
|
||||||
|
spec: { command: "definitely-not-a-real-command-xyz-42" },
|
||||||
|
reason: "need it",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nonGoals: [],
|
||||||
|
runtimeValidation: { commands: [] },
|
||||||
|
riskFlags: [],
|
||||||
|
reviewerProfile: "static" as const,
|
||||||
|
approvalGates: { impl: true, review: true, deploy: true },
|
||||||
|
};
|
||||||
|
const result = await validateContract(contract, { workdir: testDir });
|
||||||
|
expect(result.verdict).toBe("ABORT_PRECHECK");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generator + store", () => {
|
||||||
|
it("generates draft contract from sprint markdown", async () => {
|
||||||
|
const mdPath = join(testDir, "SPRINT-001.md");
|
||||||
|
await writeFile(
|
||||||
|
mdPath,
|
||||||
|
`# SPRINT-001 — Test Sprint\n\n## Type\n\`scaffold\`\n\n## Non-Goals\n\n- Skip XYZ\n- Do not do ABC\n`,
|
||||||
|
);
|
||||||
|
const draft = await generateDraftContract(mdPath, "SPRINT-001");
|
||||||
|
expect(draft.version).toBe("v1");
|
||||||
|
expect(draft.type).toBe("scaffold");
|
||||||
|
expect(draft.sprintId).toBe("SPRINT-001");
|
||||||
|
expect(draft.nonGoals).toEqual(["Skip XYZ", "Do not do ABC"]);
|
||||||
|
expect(draft.dod.checks.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves and loads a contract round-trip", async () => {
|
||||||
|
await mkdir(join(testDir, ".rails", "contracts"), { recursive: true });
|
||||||
|
const draft = {
|
||||||
|
version: "v1" as const,
|
||||||
|
id: "test-01",
|
||||||
|
sprintId: "S1",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
type: "scaffold" as const,
|
||||||
|
dod: {
|
||||||
|
checks: [
|
||||||
|
{
|
||||||
|
id: "c1",
|
||||||
|
description: "",
|
||||||
|
kind: "file_exists" as const,
|
||||||
|
spec: { path: "README.md" },
|
||||||
|
blocking: true,
|
||||||
|
severity: "major" as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
environmentPrerequisites: [],
|
||||||
|
nonGoals: [],
|
||||||
|
runtimeValidation: { commands: [] },
|
||||||
|
riskFlags: [],
|
||||||
|
reviewerProfile: "static" as const,
|
||||||
|
approvalGates: { impl: true, review: true, deploy: true },
|
||||||
|
};
|
||||||
|
await saveDraftContract(testDir, draft);
|
||||||
|
const loaded = await loadContract(testDir, "test-01");
|
||||||
|
expect(loaded.id).toBe("test-01");
|
||||||
|
expect(loaded.dod.checks[0]?.kind).toBe("file_exists");
|
||||||
|
});
|
||||||
|
});
|
||||||
157
tests/enforcement.test.ts
Normal file
157
tests/enforcement.test.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import {
|
||||||
|
createSkillContext,
|
||||||
|
readSkillContext,
|
||||||
|
clearSkillContext,
|
||||||
|
isContextExpired,
|
||||||
|
} from "../src/enforcement/skill-context.js";
|
||||||
|
import { appendTrace, readTrace, countBlocked } from "../src/enforcement/skill-trace.js";
|
||||||
|
import { checkGuard } from "../src/enforcement/guard.js";
|
||||||
|
|
||||||
|
let testDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
testDir = await mkdtemp(join(tmpdir(), "rails-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(testDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("skill-context", () => {
|
||||||
|
it("creates and reads context", async () => {
|
||||||
|
const ctx = await createSkillContext(testDir, {
|
||||||
|
skillName: "rails",
|
||||||
|
pipelineId: "01TEST",
|
||||||
|
ttlSeconds: 300,
|
||||||
|
});
|
||||||
|
expect(ctx.skillName).toBe("rails");
|
||||||
|
expect(ctx.pipelineId).toBe("01TEST");
|
||||||
|
|
||||||
|
const read = await readSkillContext(testDir);
|
||||||
|
expect(read).not.toBeNull();
|
||||||
|
expect(read!.skillName).toBe("rails");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when no context exists", async () => {
|
||||||
|
const read = await readSkillContext(testDir);
|
||||||
|
expect(read).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears context", async () => {
|
||||||
|
await createSkillContext(testDir, { skillName: "rails", ttlSeconds: 300 });
|
||||||
|
const cleared = await clearSkillContext(testDir);
|
||||||
|
expect(cleared).toBe(true);
|
||||||
|
const read = await readSkillContext(testDir);
|
||||||
|
expect(read).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects expired context", () => {
|
||||||
|
const ctx = {
|
||||||
|
skillName: "rails",
|
||||||
|
subcommand: "",
|
||||||
|
pipelineId: "",
|
||||||
|
contractId: "",
|
||||||
|
sessionId: "",
|
||||||
|
createdAt: new Date(Date.now() - 400_000).toISOString(), // 400s ago
|
||||||
|
ttlSeconds: 300,
|
||||||
|
};
|
||||||
|
expect(isContextExpired(ctx)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects valid context", () => {
|
||||||
|
const ctx = {
|
||||||
|
skillName: "rails",
|
||||||
|
subcommand: "",
|
||||||
|
pipelineId: "",
|
||||||
|
contractId: "",
|
||||||
|
sessionId: "",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
ttlSeconds: 300,
|
||||||
|
};
|
||||||
|
expect(isContextExpired(ctx)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("skill-trace", () => {
|
||||||
|
it("appends and reads trace entries", async () => {
|
||||||
|
await appendTrace(testDir, {
|
||||||
|
ts: Date.now(),
|
||||||
|
tool: "Write",
|
||||||
|
blocked: false,
|
||||||
|
reason: "ok",
|
||||||
|
});
|
||||||
|
await appendTrace(testDir, {
|
||||||
|
ts: Date.now(),
|
||||||
|
tool: "Bash",
|
||||||
|
blocked: true,
|
||||||
|
reason: "no-context",
|
||||||
|
});
|
||||||
|
|
||||||
|
const entries = await readTrace(testDir);
|
||||||
|
expect(entries).toHaveLength(2);
|
||||||
|
expect(entries[1]!.blocked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts blocked entries", async () => {
|
||||||
|
await appendTrace(testDir, { ts: Date.now(), tool: "Write", blocked: false, reason: "ok" });
|
||||||
|
await appendTrace(testDir, { ts: Date.now(), tool: "Edit", blocked: true, reason: "no-ctx" });
|
||||||
|
await appendTrace(testDir, { ts: Date.now(), tool: "Bash", blocked: true, reason: "expired" });
|
||||||
|
|
||||||
|
expect(await countBlocked(testDir)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when no trace file", async () => {
|
||||||
|
expect(await readTrace(testDir)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("guard", () => {
|
||||||
|
it("blocks when no context exists", async () => {
|
||||||
|
const result = await checkGuard(testDir, "Write");
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toContain("No skill context");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows when valid context exists", async () => {
|
||||||
|
await createSkillContext(testDir, {
|
||||||
|
skillName: "rails",
|
||||||
|
ttlSeconds: 300,
|
||||||
|
});
|
||||||
|
const result = await checkGuard(testDir, "Write");
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.reason).toBe("ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks when context is expired", async () => {
|
||||||
|
await createSkillContext(testDir, {
|
||||||
|
skillName: "rails",
|
||||||
|
ttlSeconds: 1, // 1 second TTL
|
||||||
|
});
|
||||||
|
// Wait just over 1 second
|
||||||
|
await new Promise((r) => setTimeout(r, 1100));
|
||||||
|
const result = await checkGuard(testDir, "Edit");
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toContain("expired");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows when RAILS_ENFORCE=off", async () => {
|
||||||
|
process.env["RAILS_ENFORCE"] = "off";
|
||||||
|
try {
|
||||||
|
const result = await checkGuard(testDir, "Bash");
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.reason).toBe("enforcement-off");
|
||||||
|
} finally {
|
||||||
|
delete process.env["RAILS_ENFORCE"];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records blocked calls in trace", async () => {
|
||||||
|
await checkGuard(testDir, "Write");
|
||||||
|
const entries = await readTrace(testDir);
|
||||||
|
expect(entries.some((e) => e.blocked && e.tool === "Write")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
209
tests/handoff.test.ts
Normal file
209
tests/handoff.test.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { HandoffMessage, InvokeRequest } from "../src/handoff/message.js";
|
||||||
|
import { MockTransport } from "../src/handoff/mock-transport.js";
|
||||||
|
import {
|
||||||
|
encodeInvokeMarker,
|
||||||
|
decodeResultMarker,
|
||||||
|
DiscordTransport,
|
||||||
|
type DiscordPoster,
|
||||||
|
} from "../src/handoff/discord-transport.js";
|
||||||
|
|
||||||
|
describe("HandoffMessage schema", () => {
|
||||||
|
it("parses a valid plan result", () => {
|
||||||
|
const parsed = HandoffMessage.parse({
|
||||||
|
stage: "plan",
|
||||||
|
verdict: "PLAN_READY",
|
||||||
|
payload: { planDir: "/tmp", sprintId: "S1", contractId: "c1" },
|
||||||
|
abortReason: "",
|
||||||
|
});
|
||||||
|
expect(parsed.stage).toBe("plan");
|
||||||
|
if (parsed.stage === "plan") {
|
||||||
|
expect(parsed.verdict).toBe("PLAN_READY");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a review REQUEST_CHANGES with issues", () => {
|
||||||
|
const parsed = HandoffMessage.parse({
|
||||||
|
stage: "review",
|
||||||
|
verdict: "REQUEST_CHANGES",
|
||||||
|
payload: {
|
||||||
|
artifactPath: "/tmp/r.json",
|
||||||
|
checklistResults: [],
|
||||||
|
issues: [
|
||||||
|
{ severity: "major", message: "fix this", file: "src/a.ts", line: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
abortReason: "",
|
||||||
|
});
|
||||||
|
expect(parsed.stage).toBe("review");
|
||||||
|
if (parsed.stage === "review" && parsed.payload) {
|
||||||
|
expect(parsed.payload.issues[0]!.severity).toBe("major");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid stage", () => {
|
||||||
|
expect(() =>
|
||||||
|
HandoffMessage.parse({ stage: "bogus", verdict: "PLAN_READY" }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid verdict for stage", () => {
|
||||||
|
expect(() =>
|
||||||
|
HandoffMessage.parse({ stage: "plan", verdict: "DEPLOY_DONE" }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MockTransport", () => {
|
||||||
|
it("returns PLAN_READY for plan stage by default", async () => {
|
||||||
|
const t = new MockTransport();
|
||||||
|
const req = InvokeRequest.parse({
|
||||||
|
pipelineId: "01MOCK",
|
||||||
|
stage: "plan",
|
||||||
|
role: "plan",
|
||||||
|
sprintId: "S1",
|
||||||
|
task: { title: "test" },
|
||||||
|
});
|
||||||
|
const result = await t.invoke(req);
|
||||||
|
expect(result.stage).toBe("plan");
|
||||||
|
if (result.stage === "plan") {
|
||||||
|
expect(result.verdict).toBe("PLAN_READY");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies per-pipeline scenario overrides", async () => {
|
||||||
|
const t = new MockTransport({
|
||||||
|
"01TEST:review": {
|
||||||
|
stage: "review",
|
||||||
|
verdict: "REQUEST_CHANGES",
|
||||||
|
payload: { artifactPath: "", checklistResults: [], issues: [] },
|
||||||
|
abortReason: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const req = InvokeRequest.parse({
|
||||||
|
pipelineId: "01TEST",
|
||||||
|
stage: "review",
|
||||||
|
role: "review",
|
||||||
|
task: { title: "test" },
|
||||||
|
});
|
||||||
|
const result = await t.invoke(req);
|
||||||
|
if (result.stage === "review") {
|
||||||
|
expect(result.verdict).toBe("REQUEST_CHANGES");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies stage-level overrides", async () => {
|
||||||
|
const t = new MockTransport();
|
||||||
|
t.setScenario("implement", {
|
||||||
|
stage: "implement",
|
||||||
|
verdict: "ERROR",
|
||||||
|
errorReason: "mock fail",
|
||||||
|
});
|
||||||
|
const req = InvokeRequest.parse({
|
||||||
|
pipelineId: "01ANY",
|
||||||
|
stage: "implement",
|
||||||
|
role: "implement",
|
||||||
|
task: { title: "test" },
|
||||||
|
});
|
||||||
|
const result = await t.invoke(req);
|
||||||
|
if (result.stage === "implement") {
|
||||||
|
expect(result.verdict).toBe("ERROR");
|
||||||
|
expect(result.errorReason).toBe("mock fail");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Discord marker encoding", () => {
|
||||||
|
it("encode → decode round-trip (result marker)", () => {
|
||||||
|
const original: HandoffMessage = {
|
||||||
|
stage: "implement",
|
||||||
|
verdict: "IMPL_DONE",
|
||||||
|
payload: {
|
||||||
|
branch: "feature/x",
|
||||||
|
commits: ["abc1234"],
|
||||||
|
workdir: "/tmp",
|
||||||
|
selfTestReport: { tests: "pass" },
|
||||||
|
},
|
||||||
|
errorReason: "",
|
||||||
|
};
|
||||||
|
const body =
|
||||||
|
"some natural language before\n\n" +
|
||||||
|
"<!-- rails:result v1 -->\n" +
|
||||||
|
"```json\n" +
|
||||||
|
JSON.stringify(original) +
|
||||||
|
"\n```\n" +
|
||||||
|
"<!-- /rails:result -->\n\n" +
|
||||||
|
"constructor note";
|
||||||
|
const parsed = decodeResultMarker(body);
|
||||||
|
expect(parsed.stage).toBe("implement");
|
||||||
|
if (parsed.stage === "implement" && parsed.payload) {
|
||||||
|
expect(parsed.payload.branch).toBe("feature/x");
|
||||||
|
expect(parsed.payload.commits).toEqual(["abc1234"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodeInvokeMarker produces a parseable block", () => {
|
||||||
|
const req = InvokeRequest.parse({
|
||||||
|
pipelineId: "01INV",
|
||||||
|
stage: "plan",
|
||||||
|
role: "plan",
|
||||||
|
task: { title: "go" },
|
||||||
|
});
|
||||||
|
const marker = encodeInvokeMarker(req);
|
||||||
|
expect(marker).toContain("rails:invoke");
|
||||||
|
expect(marker).toContain("01INV");
|
||||||
|
expect(marker).toContain('"stage":"plan"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodeResultMarker throws when no marker present", () => {
|
||||||
|
expect(() => decodeResultMarker("no marker here")).toThrow(/No rails:result/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DiscordTransport (fake poster)", () => {
|
||||||
|
it("posts invoke and parses result", async () => {
|
||||||
|
const fakePoster: DiscordPoster = {
|
||||||
|
async postMessage(_channelId, _content) {
|
||||||
|
return "msg-123";
|
||||||
|
},
|
||||||
|
async waitForResult() {
|
||||||
|
const result: HandoffMessage = {
|
||||||
|
stage: "plan",
|
||||||
|
verdict: "PLAN_READY",
|
||||||
|
payload: {
|
||||||
|
planDir: "/tmp/plans",
|
||||||
|
sprintId: "S1",
|
||||||
|
contractId: "c1",
|
||||||
|
},
|
||||||
|
abortReason: "",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
"<!-- rails:result v1 -->\n" +
|
||||||
|
"```json\n" +
|
||||||
|
JSON.stringify(result) +
|
||||||
|
"\n```\n" +
|
||||||
|
"<!-- /rails:result -->"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async close() {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const t = new DiscordTransport({
|
||||||
|
token: "fake",
|
||||||
|
guildId: "g1",
|
||||||
|
channelId: "c1",
|
||||||
|
poster: fakePoster,
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = InvokeRequest.parse({
|
||||||
|
pipelineId: "01DC",
|
||||||
|
stage: "plan",
|
||||||
|
role: "plan",
|
||||||
|
task: { title: "test" },
|
||||||
|
});
|
||||||
|
const result = await t.invoke(req);
|
||||||
|
if (result.stage === "plan") {
|
||||||
|
expect(result.verdict).toBe("PLAN_READY");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
137
tests/migrate.test.ts
Normal file
137
tests/migrate.test.ts
Normal 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
322
tests/qa.test.ts
Normal 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
223
tests/resilience.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user