From a88323716ded944227b0f1ac44f2345de3a8b2f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=9E=91=EC=9D=B4?= Date: Fri, 10 Apr 2026 17:11:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(stage-1-2):=20hierarchical=20sub-agent=20t?= =?UTF-8?q?eam=20=E2=80=94=20sister-agent=20+=20SubTask=20tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1 + 2 통합 구현 — 사용자 피드백 반영: manager / principal / lead / junior 계층 구조 추가. 부장이 복잡도를 판단해서 팀을 동적으로 꾸린다. Design: - .plans/design/hierarchy.md — 전체 설계 문서 (점수/tier/plan/escalation) Prisma schema: - SubTask (계층 트리 + 역할/모델/state/complexity) - SubTaskEvent (JSONL 스타일 이벤트 로그) rails (core): - src/hierarchy/roles.ts — 4역할 기본 config + 모델 매핑 manager/principal: gpt-5.4 / glm-5.1 lead: gpt-codex-5.3 / glm-5 junior: glm-5-turbo / gpt-5 - src/hierarchy/complexity.ts — 규칙 기반 스코어러 (7 factors, 0-100) - src/hierarchy/planner.ts — tier → DecompositionPlan (trivial/simple/moderate/complex/massive) + concurrency budget 강제 - src/hierarchy/store.ts — Prisma CRUD + tree builder - src/handoff/http-transport.ts — 실제 HTTP transport (MockTransport 대체) - src/handoff/build.ts — config + env 기반 transport 빌더 (env RAILS_TRANSPORT_MODE + RAILS_AGENT_{STAGE}_HOST 오버라이드) - src/server/http.ts — sub-task 엔드포인트 4개 추가 POST /api/sub-tasks PATCH /api/sub-tasks/:id POST /api/sub-tasks/:id/events GET /api/pipelines/:id/sub-tasks (tree view) sister-agent (new sub-project): - sister-agent/ — 각 LXC 에 배포될 Node.js 데몬 - src/types.ts, roles.ts, complexity.ts, planner.ts - src/spawn.ts — executeInvocation: 복잡도 점수 → plan → spawn 트리 simulation mode (현재는 work 를 delay 로 시뮬레이트, LLM 연동은 후속) - src/rails-client.ts — sub-task / event push - src/server.ts — POST /invoke 엔드포인트 (port 18801) LXC 리소스 실측 기반 기본 concurrency: 104/106/107: 8 concurrent sub-agents 105 (narang, build 중): 6 검증: tsc --noEmit ✓ | vitest 105/105 ✓ | rails build ✓ | sister-agent build ✓ Co-Authored-By: Claude Opus 4.6 (1M context) --- .plans/design/hierarchy.md | 344 ++++++ prisma/schema.prisma | 44 + .../.claude/sessions/.last_inbox_check | 1 + .../.claude/state/test-recommendation.json | 7 + sister-agent/package.json | 26 + sister-agent/pnpm-lock.yaml | 1029 +++++++++++++++++ sister-agent/src/complexity.ts | 173 +++ sister-agent/src/index.ts | 1 + sister-agent/src/planner.ts | 191 +++ sister-agent/src/rails-client.ts | 60 + sister-agent/src/roles.ts | 42 + sister-agent/src/server.ts | 117 ++ sister-agent/src/spawn.ts | 287 +++++ sister-agent/src/types.ts | 89 ++ sister-agent/tsconfig.json | 23 + src/handoff/build.ts | 83 ++ src/handoff/http-transport.ts | 109 ++ src/hierarchy/complexity.ts | 173 +++ src/hierarchy/planner.ts | 191 +++ src/hierarchy/roles.ts | 60 + src/hierarchy/store.ts | 166 +++ src/server/http.ts | 76 +- 22 files changed, 3285 insertions(+), 7 deletions(-) create mode 100644 .plans/design/hierarchy.md create mode 100644 sister-agent/.claude/sessions/.last_inbox_check create mode 100644 sister-agent/.claude/state/test-recommendation.json create mode 100644 sister-agent/package.json create mode 100644 sister-agent/pnpm-lock.yaml create mode 100644 sister-agent/src/complexity.ts create mode 100644 sister-agent/src/index.ts create mode 100644 sister-agent/src/planner.ts create mode 100644 sister-agent/src/rails-client.ts create mode 100644 sister-agent/src/roles.ts create mode 100644 sister-agent/src/server.ts create mode 100644 sister-agent/src/spawn.ts create mode 100644 sister-agent/src/types.ts create mode 100644 sister-agent/tsconfig.json create mode 100644 src/handoff/build.ts create mode 100644 src/handoff/http-transport.ts create mode 100644 src/hierarchy/complexity.ts create mode 100644 src/hierarchy/planner.ts create mode 100644 src/hierarchy/roles.ts create mode 100644 src/hierarchy/store.ts diff --git a/.plans/design/hierarchy.md b/.plans/design/hierarchy.md new file mode 100644 index 0000000..94d6e37 --- /dev/null +++ b/.plans/design/hierarchy.md @@ -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 합산 로직 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6a4096b..0b7face 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -20,6 +20,7 @@ model Pipeline { actorSpawns ActorSpawn[] contracts Contract[] escalations Escalation[] + subTasks SubTask[] @@index([currentState]) @@index([createdAt]) @@ -74,6 +75,49 @@ model Contract { @@map("contracts") } +model SubTask { + id String @id @db.VarChar(26) + pipelineId String @db.VarChar(26) + parentId String? @db.VarChar(26) + role String @db.VarChar(30) + agentName String @db.VarChar(50) + title String @db.VarChar(500) + description String @db.Text + state String @db.VarChar(30) @default("queued") + complexityScore Int? + complexityTier String? @db.VarChar(20) + model String @db.VarChar(50) @default("") + resultJson String? @db.LongText + errorReason String? @db.Text + startedAt DateTime? + completedAt DateTime? + createdAt DateTime @default(now()) + + pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade) + parent SubTask? @relation("SubTaskHierarchy", fields: [parentId], references: [id], onDelete: NoAction, onUpdate: NoAction) + children SubTask[] @relation("SubTaskHierarchy") + events SubTaskEvent[] + + @@index([pipelineId, parentId]) + @@index([state]) + @@index([agentName, state]) + @@map("sub_tasks") +} + +model SubTaskEvent { + id Int @id @default(autoincrement()) + subTaskId String @db.VarChar(26) + eventType String @db.VarChar(50) + payloadJson String @db.LongText + timestamp DateTime @default(now()) + + subTask SubTask @relation(fields: [subTaskId], references: [id], onDelete: Cascade) + + @@index([subTaskId, timestamp]) + @@index([eventType]) + @@map("sub_task_events") +} + model Escalation { id String @id @db.VarChar(26) // ULID pipelineId String @db.VarChar(26) diff --git a/sister-agent/.claude/sessions/.last_inbox_check b/sister-agent/.claude/sessions/.last_inbox_check new file mode 100644 index 0000000..09f9548 --- /dev/null +++ b/sister-agent/.claude/sessions/.last_inbox_check @@ -0,0 +1 @@ +1775808589 diff --git a/sister-agent/.claude/state/test-recommendation.json b/sister-agent/.claude/state/test-recommendation.json new file mode 100644 index 0000000..8e5dc71 --- /dev/null +++ b/sister-agent/.claude/state/test-recommendation.json @@ -0,0 +1,7 @@ +{ + "timestamp": "2026-04-10T08:10:32Z", + "changed_file": "/home/erang/hanarang-rails/src/server/http.ts", + "test_command": "npm test", + "related_test": "", + "recommendation": "テストの実行を推奨します" +} diff --git a/sister-agent/package.json b/sister-agent/package.json new file mode 100644 index 0000000..aa20d93 --- /dev/null +++ b/sister-agent/package.json @@ -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" +} diff --git a/sister-agent/pnpm-lock.yaml b/sister-agent/pnpm-lock.yaml new file mode 100644 index 0000000..30abd3f --- /dev/null +++ b/sister-agent/pnpm-lock.yaml @@ -0,0 +1,1029 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + ulid: + specifier: ^2.3.0 + version: 2.4.0 + zod: + specifier: ^3.24.0 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 + typescript: + specifier: ^5.8.0 + version: 5.9.3 + vitest: + specifier: ^3.1.0 + version: 3.2.4(@types/node@22.19.17) + +packages: + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.9: + resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ulid@2.4.0: + resolution: {integrity: sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg==} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.2: + resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rollup/rollup-android-arm-eabi@4.60.1': + optional: true + + '@rollup/rollup-android-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-x64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.1': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@22.19.17))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.2(@types/node@22.19.17) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + assertion-error@2.0.1: {} + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + js-tokens@9.0.1: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.9: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.60.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.1 + '@rollup/rollup-android-arm64': 4.60.1 + '@rollup/rollup-darwin-arm64': 4.60.1 + '@rollup/rollup-darwin-x64': 4.60.1 + '@rollup/rollup-freebsd-arm64': 4.60.1 + '@rollup/rollup-freebsd-x64': 4.60.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 + '@rollup/rollup-linux-arm-musleabihf': 4.60.1 + '@rollup/rollup-linux-arm64-gnu': 4.60.1 + '@rollup/rollup-linux-arm64-musl': 4.60.1 + '@rollup/rollup-linux-loong64-gnu': 4.60.1 + '@rollup/rollup-linux-loong64-musl': 4.60.1 + '@rollup/rollup-linux-ppc64-gnu': 4.60.1 + '@rollup/rollup-linux-ppc64-musl': 4.60.1 + '@rollup/rollup-linux-riscv64-gnu': 4.60.1 + '@rollup/rollup-linux-riscv64-musl': 4.60.1 + '@rollup/rollup-linux-s390x-gnu': 4.60.1 + '@rollup/rollup-linux-x64-gnu': 4.60.1 + '@rollup/rollup-linux-x64-musl': 4.60.1 + '@rollup/rollup-openbsd-x64': 4.60.1 + '@rollup/rollup-openharmony-arm64': 4.60.1 + '@rollup/rollup-win32-arm64-msvc': 4.60.1 + '@rollup/rollup-win32-ia32-msvc': 4.60.1 + '@rollup/rollup-win32-x64-gnu': 4.60.1 + '@rollup/rollup-win32-x64-msvc': 4.60.1 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + typescript@5.9.3: {} + + ulid@2.4.0: {} + + undici-types@6.21.0: {} + + vite-node@3.2.4(@types/node@22.19.17): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.2(@types/node@22.19.17) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.2(@types/node@22.19.17): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.9 + rollup: 4.60.1 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 22.19.17 + fsevents: 2.3.3 + + vitest@3.2.4(@types/node@22.19.17): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@22.19.17)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.16 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.2(@types/node@22.19.17) + vite-node: 3.2.4(@types/node@22.19.17) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.17 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + zod@3.25.76: {} diff --git a/sister-agent/src/complexity.ts b/sister-agent/src/complexity.ts new file mode 100644 index 0000000..e3e5664 --- /dev/null +++ b/sister-agent/src/complexity.ts @@ -0,0 +1,173 @@ +import { z } from "zod"; + +export const ComplexityTier = z.enum([ + "trivial", + "simple", + "moderate", + "complex", + "massive", +]); +export type ComplexityTier = z.infer; + +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"; +} diff --git a/sister-agent/src/index.ts b/sister-agent/src/index.ts new file mode 100644 index 0000000..4bb257a --- /dev/null +++ b/sister-agent/src/index.ts @@ -0,0 +1 @@ +import "./server.js"; diff --git a/sister-agent/src/planner.ts b/sister-agent/src/planner.ts new file mode 100644 index 0000000..cae451b --- /dev/null +++ b/sister-agent/src/planner.ts @@ -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; +} diff --git a/sister-agent/src/rails-client.ts b/sister-agent/src/rails-client.ts new file mode 100644 index 0000000..37f670f --- /dev/null +++ b/sister-agent/src/rails-client.ts @@ -0,0 +1,60 @@ +import type { SubTaskRecord } from "./types.js"; + +export class RailsClient { + constructor(private readonly baseUrl: string) {} + + async createSubTask(record: SubTaskRecord): Promise { + await this.request("POST", "/api/sub-tasks", record); + } + + async recordEvent( + subTaskId: string, + eventType: string, + payload: Record, + ): Promise { + await this.request( + "POST", + `/api/sub-tasks/${subTaskId}/events`, + { eventType, payload }, + ); + } + + async patchSubTask( + id: string, + patch: Record, + ): Promise { + await this.request("PATCH", `/api/sub-tasks/${id}`, patch); + } + + private async request( + method: string, + path: string, + body?: unknown, + ): Promise { + 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; + } + } +} diff --git a/sister-agent/src/roles.ts b/sister-agent/src/roles.ts new file mode 100644 index 0000000..aec7ed9 --- /dev/null +++ b/sister-agent/src/roles.ts @@ -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 = { + 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 = { + manager: "부장", + principal: "수석", + lead: "선임", + junior: "신입", +}; diff --git a/sister-agent/src/server.ts b/sister-agent/src/server.ts new file mode 100644 index 0000000..723b8ef --- /dev/null +++ b/sister-agent/src/server.ts @@ -0,0 +1,117 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { InvokeRequest } from "./types.js"; +import { executeInvocation } from "./spawn.js"; +import { RailsClient } from "./rails-client.js"; + +const PORT = parseInt(process.env["SISTER_AGENT_PORT"] ?? "18801", 10); +const AGENT_NAME = process.env["SISTER_AGENT_NAME"] ?? "unknown"; + +const log = (level: string, msg: string, meta?: Record): 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 { + return new Promise((resolveFn, rejectFn) => { + let body = ""; + req.on("data", (chunk: Buffer) => (body += chunk.toString())); + req.on("end", () => { + if (!body) return resolveFn({}); + try { + resolveFn(JSON.parse(body)); + } catch (err) { + rejectFn(err); + } + }); + req.on("error", rejectFn); + }); +} + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { + "content-type": "application/json", + "cache-control": "no-store", + }); + res.end(JSON.stringify(body)); +} + +const server = createServer(async (req, res) => { + const url = new URL(req.url ?? "/", `http://localhost:${PORT}`); + const path = url.pathname; + const method = req.method ?? "GET"; + + log("info", "request", { method, path }); + + try { + if (method === "GET" && path === "/health") { + return sendJson(res, 200, { + ok: true, + service: "sister-agent", + agent: AGENT_NAME, + }); + } + + if (method === "POST" && path === "/invoke") { + const body = await readJson(req); + const parsed = InvokeRequest.safeParse(body); + if (!parsed.success) { + return sendJson(res, 400, { + error: "invalid_invoke", + issues: parsed.error.issues, + }); + } + + const req2 = { ...parsed.data, agentName: parsed.data.agentName || AGENT_NAME }; + const railsClient = new RailsClient(req2.railsApiUrl); + + log("info", "invoke.start", { + pipelineId: req2.pipelineId, + stage: req2.stage, + }); + + try { + const result = await executeInvocation(req2, railsClient); + log("info", "invoke.done", { + pipelineId: req2.pipelineId, + stage: req2.stage, + verdict: "verdict" in result ? result.verdict : "?", + }); + return sendJson(res, 200, result); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log("error", "invoke.error", { + pipelineId: req2.pipelineId, + stage: req2.stage, + error: msg, + }); + return sendJson(res, 500, { error: "invocation_failed", message: msg }); + } + } + + return sendJson(res, 404, { error: "not_found" }); + } catch (err) { + log("error", "request.error", { + error: err instanceof Error ? err.message : String(err), + }); + return sendJson(res, 500, { error: "internal_error" }); + } +}); + +server.listen(PORT, "0.0.0.0", () => { + log("info", "sister-agent listening", { port: PORT, agent: AGENT_NAME }); +}); + +const shutdown = (signal: string): void => { + log("info", "shutdown", { signal }); + server.close(() => process.exit(0)); +}; + +process.on("SIGINT", () => shutdown("SIGINT")); +process.on("SIGTERM", () => shutdown("SIGTERM")); diff --git a/sister-agent/src/spawn.ts b/sister-agent/src/spawn.ts new file mode 100644 index 0000000..30a478f --- /dev/null +++ b/sister-agent/src/spawn.ts @@ -0,0 +1,287 @@ +import { ulid } from "ulid"; +import type { Role, SubTaskRecord, InvokeRequest, HandoffMessage } from "./types.js"; +import { ROLES } from "./roles.js"; +import { scoreComplexity, type ComplexityScore } from "./complexity.js"; +import { planDecomposition, type DecompositionPlan } from "./planner.js"; +import type { RailsClient } from "./rails-client.js"; + +/** + * Execute an invocation using the hierarchical team strategy. + * + * Current implementation is a **simulation-only** executor: it creates + * the full sub-task tree in rails DB and streams events, but does not + * actually call LLMs. This gives us the full observable hierarchy without + * requiring openclaw CLI integration to be wired up yet. + * + * Swap in real LLM calls by replacing executeRole(). + */ +export async function executeInvocation( + req: InvokeRequest, + rails: RailsClient, +): Promise { + 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 { + // If no children planned for this role, execute directly + const hasChildren = + depth === 0 && plan.spawn.length > 0 && plan.strategy !== "direct"; + + if (!hasChildren) { + // Leaf execution — in this simulation we just produce a success result + await simulateWork(role); + return buildSuccessResult(req.stage, req.task); + } + + // Spawn children per plan + for (const spawnPlan of plan.spawn) { + for (let i = 0; i < spawnPlan.count; i++) { + const childId = ulid(); + const childRecord: SubTaskRecord = { + id: childId, + pipelineId: req.pipelineId, + parentId: selfId, + role: spawnPlan.role, + agentName, + title: `${spawnPlan.role}-${i + 1}: ${req.task.title.slice(0, 100)}`, + description: spawnPlan.rationale, + complexityScore: null, + complexityTier: null, + model: ROLES[spawnPlan.role].primaryModel, + }; + await rails.createSubTask(childRecord); + await rails.recordEvent(childId, "spawned", { + parent: selfId, + role: spawnPlan.role, + }); + await rails.recordEvent(childId, "started", {}); + + // Recursively spawn grandchildren if subBreakdown exists + if (spawnPlan.subBreakdown && spawnPlan.subBreakdown.length > 0) { + for (const grandSpawn of spawnPlan.subBreakdown) { + for (let j = 0; j < grandSpawn.count; j++) { + const grandId = ulid(); + const grandRecord: SubTaskRecord = { + id: grandId, + pipelineId: req.pipelineId, + parentId: childId, + role: grandSpawn.role, + agentName, + title: `${grandSpawn.role}-${j + 1}`, + description: grandSpawn.rationale, + complexityScore: null, + complexityTier: null, + model: ROLES[grandSpawn.role].primaryModel, + }; + await rails.createSubTask(grandRecord); + await rails.recordEvent(grandId, "spawned", { + parent: childId, + role: grandSpawn.role, + }); + await rails.recordEvent(grandId, "started", {}); + + // Third-level (junior) grand-grandchildren + if (grandSpawn.subBreakdown && grandSpawn.subBreakdown.length > 0) { + for (const ggSpawn of grandSpawn.subBreakdown) { + for (let k = 0; k < ggSpawn.count; k++) { + const ggId = ulid(); + await rails.createSubTask({ + id: ggId, + pipelineId: req.pipelineId, + parentId: grandId, + role: ggSpawn.role, + agentName, + title: `${ggSpawn.role}-${k + 1}`, + description: ggSpawn.rationale, + complexityScore: null, + complexityTier: null, + model: ROLES[ggSpawn.role].primaryModel, + }); + await rails.recordEvent(ggId, "spawned", { + parent: grandId, + role: ggSpawn.role, + }); + await rails.recordEvent(ggId, "started", {}); + await simulateWork(ggSpawn.role); + await rails.recordEvent(ggId, "completed", { ok: true }); + } + } + } else { + await simulateWork(grandSpawn.role); + } + await rails.recordEvent(grandId, "completed", { ok: true }); + } + } + } else { + await simulateWork(spawnPlan.role); + } + await rails.recordEvent(childId, "completed", { ok: true }); + } + } + + void complexity; // reserved for future LLM-based planning + return buildSuccessResult(req.stage, req.task); +} + +/** + * Placeholder "work" — tiny delay per role so timeline looks realistic. + * Replace with real openclaw agent CLI or LLM SDK call. + */ +async function simulateWork(role: Role): Promise { + const delayByRole: Record = { + manager: 40, + principal: 60, + lead: 80, + junior: 100, + }; + await new Promise((r) => setTimeout(r, delayByRole[role])); +} + +function buildSuccessResult( + stage: InvokeRequest["stage"], + task: InvokeRequest["task"], +): HandoffMessage { + switch (stage) { + case "plan": + return { + stage: "plan", + verdict: "PLAN_READY", + payload: { + planDir: ".plans", + sprintId: "SPRINT-AUTO", + contractId: "", + }, + abortReason: "", + }; + case "implement": + return { + stage: "implement", + verdict: "IMPL_DONE", + payload: { + branch: "feature/sister-agent", + commits: ["simulated"], + workdir: task.workdir || "", + selfTestReport: { simulated: true }, + }, + errorReason: "", + }; + case "review": + return { + stage: "review", + verdict: "APPROVE", + payload: { + artifactPath: "", + checklistResults: [], + issues: [], + }, + abortReason: "", + }; + case "deploy": + return { + stage: "deploy", + verdict: "DEPLOY_DONE", + payload: { + deployArtifactPath: "", + projectType: "simulated", + verificationResults: {}, + }, + errorReason: "", + }; + } +} + +function buildErrorResult( + stage: InvokeRequest["stage"], + reason: string, +): HandoffMessage { + switch (stage) { + case "plan": + return { stage: "plan", verdict: "ABORT", abortReason: reason }; + case "implement": + return { stage: "implement", verdict: "ERROR", errorReason: reason }; + case "review": + return { stage: "review", verdict: "ABORT", abortReason: reason }; + case "deploy": + return { stage: "deploy", verdict: "DEPLOY_FAILED", errorReason: reason }; + } +} + +function countPlanNodes(plan: DecompositionPlan): number { + const inner = (spawns: DecompositionPlan["spawn"]): number => { + let total = 0; + for (const s of spawns) { + total += s.count; + if (s.subBreakdown) total += s.count * inner(s.subBreakdown); + } + return total; + }; + return 1 + inner(plan.spawn); +} diff --git a/sister-agent/src/types.ts b/sister-agent/src/types.ts new file mode 100644 index 0000000..f008c3b --- /dev/null +++ b/sister-agent/src/types.ts @@ -0,0 +1,89 @@ +import { z } from "zod"; + +// ── Roles ── +export const Role = z.enum(["manager", "principal", "lead", "junior"]); +export type Role = z.infer; + +// ── 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; + +// ── 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; + +// ── 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; +} diff --git a/sister-agent/tsconfig.json b/sister-agent/tsconfig.json new file mode 100644 index 0000000..1c46b07 --- /dev/null +++ b/sister-agent/tsconfig.json @@ -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"] +} diff --git a/src/handoff/build.ts b/src/handoff/build.ts new file mode 100644 index 0000000..72c8686 --- /dev/null +++ b/src/handoff/build.ts @@ -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 { + const transports = new Map(); + 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; +} diff --git a/src/handoff/http-transport.ts b/src/handoff/http-transport.ts new file mode 100644 index 0000000..f2003dc --- /dev/null +++ b/src/handoff/http-transport.ts @@ -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; + + 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 { + 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 { + 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 { + // HTTP client is stateless; no cleanup needed + } +} diff --git a/src/hierarchy/complexity.ts b/src/hierarchy/complexity.ts new file mode 100644 index 0000000..e3e5664 --- /dev/null +++ b/src/hierarchy/complexity.ts @@ -0,0 +1,173 @@ +import { z } from "zod"; + +export const ComplexityTier = z.enum([ + "trivial", + "simple", + "moderate", + "complex", + "massive", +]); +export type ComplexityTier = z.infer; + +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"; +} diff --git a/src/hierarchy/planner.ts b/src/hierarchy/planner.ts new file mode 100644 index 0000000..f4ff3f9 --- /dev/null +++ b/src/hierarchy/planner.ts @@ -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; +} diff --git a/src/hierarchy/roles.ts b/src/hierarchy/roles.ts new file mode 100644 index 0000000..ac7d254 --- /dev/null +++ b/src/hierarchy/roles.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; + +export const Role = z.enum(["manager", "principal", "lead", "junior"]); +export type Role = z.infer; + +export const ROLE_KOREAN: Record = { + 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 = { + 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; +} + +export const DEFAULT_CONCURRENCY: ConcurrencyLimits = { + default: 8, + overrides: { + narang: 6, // tighter when a build is running + }, +}; diff --git a/src/hierarchy/store.ts b/src/hierarchy/store.ts new file mode 100644 index 0000000..2eb4770 --- /dev/null +++ b/src/hierarchy/store.ts @@ -0,0 +1,166 @@ +import { z } from "zod"; +import { getPrisma } from "../orchestrator/persist.js"; +import { Role } from "./roles.js"; +import { ComplexityTier } from "./complexity.js"; +import { childLogger } from "../logger.js"; + +const log = childLogger({ module: "sub-task-store" }); + +export const CreateSubTaskInput = z.object({ + id: z.string().min(1), + pipelineId: z.string().min(1), + parentId: z.string().nullable().default(null), + role: Role, + agentName: z.string().min(1), + title: z.string(), + description: z.string().default(""), + complexityScore: z.number().int().nullable().default(null), + complexityTier: ComplexityTier.nullable().default(null), + model: z.string().default(""), +}); +export type CreateSubTaskInput = z.infer; + +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; + +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; + +export async function createSubTask(input: CreateSubTaskInput): Promise { + 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 { + 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 { + 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 = { + started: "running", + completed: "done", + failed: "failed", + escalated: "escalated", + }; + const newState = stateMap[input.eventType]; + if (newState) { + const patch: UpdateSubTaskInput = { state: newState as UpdateSubTaskInput["state"] }; + if (input.eventType === "started") { + patch.startedAt = new Date().toISOString(); + } else if (["completed", "failed", "escalated"].includes(input.eventType)) { + patch.completedAt = new Date().toISOString(); + } + await prisma.subTask.update({ + where: { id: input.subTaskId }, + data: { + ...patch, + startedAt: patch.startedAt ? new Date(patch.startedAt) : undefined, + completedAt: patch.completedAt ? new Date(patch.completedAt) : undefined, + }, + }); + } +} + +export async function getSubTaskTree(pipelineId: string): Promise { + 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>(); + 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; +} diff --git a/src/server/http.ts b/src/server/http.ts index ca43f26..aa8f9cc 100644 --- a/src/server/http.ts +++ b/src/server/http.ts @@ -8,8 +8,17 @@ import { } from "../orchestrator/persist.js"; import { runPipeline } from "../orchestrator/runner.js"; import { loadConfig } from "../config/loader.js"; -import { MockTransport } from "../handoff/mock-transport.js"; import type { SisterTransport } from "../handoff/transport.js"; +import { buildTransports } from "../handoff/build.js"; +import { + CreateSubTaskInput, + SubTaskEventInput, + UpdateSubTaskInput, + createSubTask, + recordSubTaskEvent, + updateSubTask, + getSubTaskTree, +} from "../hierarchy/store.js"; import { childLogger } from "../logger.js"; const log = childLogger({ module: "http-server" }); @@ -37,12 +46,8 @@ export async function startHttpServer(opts: ServerOpts): Promise<{ const host = opts.host ?? "0.0.0.0"; const config = await loadConfig(opts.configPath); - // Build transport map (mock for now; real transports wired in follow-up) - const transports = new Map(); - const mock = new MockTransport(); - for (const stage of config.pipeline.stages) { - transports.set(stage, mock); - } + // Build transport map from config + env (auto picks mock or http) + const transports: Map = buildTransports(config); const server = createServer(async (req, res) => { const url = new URL(req.url ?? "/", `http://${host}`); @@ -136,6 +141,63 @@ export async function startHttpServer(opts: ServerOpts): Promise<{ 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), + subTaskId: id, + }); + if (!parsed.success) { + return sendJson(res, 400, { + error: "invalid_event", + issues: parsed.error.issues, + }); + } + await recordSubTaskEvent(parsed.data); + return sendJson(res, 201, { ok: true }); + } + + // ── Sub-task tree by pipeline ── + const treeMatch = path.match(/^\/api\/pipelines\/([^/]+)\/sub-tasks$/); + if (method === "GET" && treeMatch) { + const pid = treeMatch[1]!; + const tree = await getSubTaskTree(pid); + return sendJson(res, 200, { pipelineId: pid, tree }); + } + return sendJson(res, 404, { error: "not_found", path }); } catch (err) { log.error(