+ );
+}
+
+function AccountLink() {
+ return (
+ <>
+ {" · "}
+
+ GitHub 연결하러 가기
+
+ >
+ );
+}
diff --git a/src/entities/room/api/roomApi.ts b/src/entities/room/api/roomApi.ts
new file mode 100644
index 0000000..63df325
--- /dev/null
+++ b/src/entities/room/api/roomApi.ts
@@ -0,0 +1,45 @@
+import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
+import { apiFetch, livePoll, type Schemas } from "@/shared/api";
+import type { TeamRole } from "@/shared/model";
+
+export const roomKeys = {
+ all: ["room"] as const,
+ project: (projectId: string) => [...roomKeys.all, projectId] as const,
+ list: (projectId: string) => [...roomKeys.project(projectId), "list"] as const,
+ feed: (projectId: string, role: TeamRole) => [...roomKeys.project(projectId), "feed", role] as const,
+};
+
+/** 내가 볼 수 있는 룸 (FRONTEND, BACKEND 순). 팀원은 자기 에이전트가 배정된 역할 룸만, 대표는 전부 */
+export function useRooms(projectId: string) {
+ return useQuery({
+ queryKey: roomKeys.list(projectId),
+ queryFn: () => apiFetch<{ rooms: Schemas["RoomSummary"][] }>(`/projects/${projectId}/rooms`),
+ select: (data) => data.rooms,
+ refetchInterval: livePoll(5000),
+ refetchIntervalInBackground: false,
+ });
+}
+
+const LIMIT = 50;
+
+type Page = { messages: Schemas["RoomMessage"][]; nextBefore: string | null };
+
+/**
+ * 룸 피드 (최신 줄부터). 문장(text)은 서버가 만든다 — 화면은 그대로 보여 준다. 실시간 신호는 room 토픽.
+ * 다른 역할의 룸이면 403 ROOM_NOT_VISIBLE
+ */
+export function useRoomFeed(projectId: string, role: TeamRole) {
+ return useInfiniteQuery({
+ queryKey: roomKeys.feed(projectId, role),
+ queryFn: ({ pageParam }) => {
+ const q = new URLSearchParams({ limit: String(LIMIT) });
+ if (pageParam) q.set("before", pageParam);
+ return apiFetch(`/projects/${projectId}/rooms/${role}/feed?${q}`);
+ },
+ initialPageParam: null as string | null,
+ getNextPageParam: (last) => last.nextBefore,
+ select: (data) => data.pages.flatMap((p) => p.messages),
+ refetchInterval: livePoll(4000),
+ refetchIntervalInBackground: false,
+ });
+}
diff --git a/src/entities/room/index.ts b/src/entities/room/index.ts
index 298f480..932f25d 100644
--- a/src/entities/room/index.ts
+++ b/src/entities/room/index.ts
@@ -1,2 +1,3 @@
export type { Room, RoomType } from "./model/types";
-export { ROOM_META } from "./model/types";
+export { ROLE_ROOM, ROOM_META, roleOfRoomSlug } from "./model/types";
+export { roomKeys, useRoomFeed, useRooms } from "./api/roomApi";
diff --git a/src/entities/room/model/types.ts b/src/entities/room/model/types.ts
index 0eaf553..be8b28c 100644
--- a/src/entities/room/model/types.ts
+++ b/src/entities/room/model/types.ts
@@ -1,3 +1,5 @@
+import type { TeamRole } from "@/shared/model";
+
export type RoomType = "OWNER" | "FE" | "BE";
export interface Room {
@@ -11,3 +13,12 @@ export const ROOM_META: Record }> = {
+ FRONTEND: { slug: "frontend", type: "FE" },
+ BACKEND: { slug: "backend", type: "BE" },
+};
+
+export const roleOfRoomSlug = (slug: string) =>
+ (Object.keys(ROLE_ROOM) as TeamRole[]).find((role) => ROLE_ROOM[role].slug === slug);
diff --git a/src/entities/task/api/taskApi.ts b/src/entities/task/api/taskApi.ts
index b38bbdc..112e450 100644
--- a/src/entities/task/api/taskApi.ts
+++ b/src/entities/task/api/taskApi.ts
@@ -1,4 +1,4 @@
-import { useQuery } from "@tanstack/react-query";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch, livePoll, type Schemas } from "@/shared/api";
import type { TaskFilters } from "../model/api";
@@ -25,3 +25,16 @@ export function useTasks(projectId: string, filters: TaskFilters = {}) {
refetchIntervalInBackground: false,
});
}
+
+/**
+ * 대표 전용. 에이전트가 제출 없이 멈춘 태스크(BLOCKED · AGENT_STOPPED)를 READY로 되돌린다. 재시도 횟수는 오르지 않는다.
+ * 409 TASK_NOT_STOPPED도 상태가 바뀐 것이라 성공·실패 모두 목록을 다시 읽는다
+ */
+export function useResumeTask() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ taskId, note }: { taskId: string; note?: string }) =>
+ apiFetch(`/tasks/${taskId}/resume`, { method: "POST", body: note ? { note } : {} }),
+ onSettled: () => queryClient.invalidateQueries({ queryKey: taskKeys.all }),
+ });
+}
diff --git a/src/entities/task/index.ts b/src/entities/task/index.ts
index 0d21d9c..3907a7d 100644
--- a/src/entities/task/index.ts
+++ b/src/entities/task/index.ts
@@ -1,7 +1,8 @@
export type { Task, TaskState } from "./model/types";
export { TASK_META, TaskBadge } from "./ui/TaskBadge";
+export { BLOCKED_REASON } from "./model/api";
export type { ApiTaskState, TaskFilters } from "./model/api";
export { TASK_BOARD } from "./model/board";
-export { taskKeys, useTasks } from "./api/taskApi";
+export { taskKeys, useResumeTask, useTasks } from "./api/taskApi";
export { API_TASK_META, ApiTaskBadge } from "./ui/ApiTaskBadge";
export { TEAM_ROLE_META, TeamRoleBadge } from "./ui/TeamRoleBadge";
diff --git a/src/entities/task/model/api.ts b/src/entities/task/model/api.ts
index b09b827..44009f0 100644
--- a/src/entities/task/model/api.ts
+++ b/src/entities/task/model/api.ts
@@ -8,3 +8,14 @@ export interface TaskFilters {
state?: ApiTaskState;
teamRole?: TeamRole;
}
+
+/**
+ * BLOCKED의 이유. 둘은 풀리는 방법이 다르다 — 화면에서 구분해 보여 준다.
+ * QUESTION은 사람이 답하면 자동으로 풀리고, AGENT_STOPPED는 대표가 원인을 해결한 뒤 재개해야 한다
+ */
+export const BLOCKED_REASON: Record, string> = {
+ QUESTION: "답을 기다리는 중",
+ AGENT_STOPPED: "에이전트가 멈춤 · 대표 확인 필요",
+ DISPUTE: "DISPUTE",
+ DEPENDENCY: "DEPENDENCY",
+};
diff --git a/src/shared/api/errors.ts b/src/shared/api/errors.ts
index b8f88f0..67b677e 100644
--- a/src/shared/api/errors.ts
+++ b/src/shared/api/errors.ts
@@ -29,7 +29,11 @@ const ERROR_MESSAGES: Record = {
TASK_ROLE_MISMATCH: "다른 역할의 태스크입니다.",
NOT_TASK_ASSIGNEE: "이 태스크의 담당자가 아닙니다.",
AGENT_NOT_IN_ORG: "이 조직에 속한 에이전트가 아닙니다.",
- PROJECT_STARTED: "프로젝트가 시작된 뒤에는 멤버를 바꿀 수 없습니다.",
+ PROJECT_STARTED: "프로젝트가 시작된 뒤에는 바꿀 수 없습니다.",
+ GITHUB_FORBIDDEN:
+ "GitHub가 거부했습니다. 그 조직에 레포를 만들 권한이 없거나, 조직이 NOMOS 앱을 승인하지 않았습니다.",
+ ROOM_NOT_VISIBLE: "다른 역할의 룸은 볼 수 없습니다.",
+ NOT_QUESTION_TARGET: "이 질문에 답할 역할의 담당자나 대표만 답할 수 있습니다.",
VERIFICATION_STAGE_NOT_REPORTABLE: "보고할 수 없는 검증 단계입니다.",
// 404
@@ -43,12 +47,17 @@ const ERROR_MESSAGES: Record = {
MEMBER_NOT_FOUND: "멤버를 찾을 수 없습니다.",
PLAN_NOT_FOUND: "계획을 찾을 수 없습니다.",
APPROVAL_NOT_FOUND: "승인 요청을 찾을 수 없습니다.",
+ QUESTION_NOT_FOUND: "질문을 찾을 수 없습니다.",
DEVICE_REQUEST_NOT_FOUND: "연결 요청을 찾을 수 없습니다. 코드를 확인하거나 터미널에서 다시 실행해 주세요.",
// 409
LOGIN_ID_TAKEN: "이미 사용 중인 아이디입니다.",
ALREADY_IN_ORG: "이미 다른 조직에 속해 있습니다.",
GITHUB_ACCOUNT_TAKEN: "이 GitHub 계정은 다른 사용자에게 연결되어 있습니다.",
+ GITHUB_NOT_LINKED: "대표가 GitHub를 연결하지 않았습니다.",
+ GITHUB_REPO_NAME_TAKEN: "같은 이름의 레포가 이미 있습니다. 다른 이름을 쓰세요.",
+ TASK_NOT_STOPPED: "멈춘 태스크가 아닙니다.",
+ QUESTION_CLOSED: "이미 답했거나 만료된 질문입니다.",
REPO_ALREADY_CONNECTED: "이미 연결된 레포지토리입니다.",
PATH_PATTERN_DUPLICATE: "같은 경로 패턴이 이미 있습니다.",
PATH_PRIORITY_TAKEN: "같은 우선순위의 규칙이 이미 있습니다.",
@@ -82,6 +91,7 @@ const ERROR_MESSAGES: Record = {
NOTE_INVALID: "인계 노트 형식이 올바르지 않습니다.",
INVALID_AUTONOMY_PRESET: "허용 레벨은 L1~L4 중 하나여야 합니다.",
REPO_OWNERSHIP_NOT_SET: "경로 소유 역할이 지정되지 않은 레포지토리입니다.",
+ QUESTION_INVALID: "모든 질문에 답해 주세요.",
// 아래 둘은 details: [{ where, message }]에 이유가 전부 온다. 화면은 details를 목록으로 함께 보여준다
PROJECT_START_INVALID: "프로젝트를 시작할 수 없습니다.",
PLAN_INVALID: "명세·태스크 검증을 통과하지 못했습니다.",
diff --git a/src/shared/api/schema.d.ts b/src/shared/api/schema.d.ts
index cba8536..5114b01 100644
--- a/src/shared/api/schema.d.ts
+++ b/src/shared/api/schema.d.ts
@@ -964,7 +964,8 @@ export interface paths {
* GitHub에서 접근 가능한 레포 목록 (조직 멤버)
* @description 레포 연결이 조직 멤버 누구나 할 수 있으므로, 그 드롭다운을 채우는 이 목록도 함께 열려 있다.
*
- * `GITHUB_TOKEN`이 없으면 **500이 아니라 빈 배열**을 반환한다.
+ * **그 조직 대표의 GitHub 토큰으로** 읽는다(서버 공용 토큰은 없다 — 있으면 모든 조직이 그 주인의 레포를 보게 된다).
+ * 대표가 GitHub를 연결하지 않았으면 **500이 아니라 빈 배열**을 반환한다(직접 입력으로 연결할 수 있다).
* GitHub 실패가 우리 기능을 멈추게 하지 않는다.
*/
get: {
@@ -1019,6 +1020,186 @@ export interface paths {
};
};
put?: never;
+ /**
+ * GitHub 조직에 레포 만들고 연결 (대표 전용)
+ * @description 대표의 GitHub 토큰으로 **GitHub 조직에 비공개 레포**를 만들고 곧바로 NOMOS에 연결한다(개인 계정에는 만들지 않는다).
+ * 프로젝트 생성 화면에서 이걸 부른 뒤 받은 `id`를 `repoIds`에 넣으면 된다.
+ *
+ * - 첫 커밋은 **파일 없는 빈 커밋** 하나다(README 없음). 브랜치가 있어야 에이전트가 분기할 수 있다.
+ * - `ownerRole`은 필수다 — `**` 행의 소유 역할. 없으면 프로젝트에 넣을 수 없다(422 `REPO_OWNERSHIP_NOT_SET`).
+ * - `githubRepoId`·`cloneUrl`이 자동으로 채워져 V3(실제 diff 검사)가 처음부터 돈다.
+ * - GitHub에 레포를 만든 뒤 연결이 실패하면 GitHub에는 레포가 남는다. 그때는 `POST /orgs/{orgId}/repos`로 연결한다.
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @example {{ORG_ID}} */
+ orgId: components["parameters"]["OrgId"];
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ /**
+ * @example {
+ * "githubOrg": "Capstone-NOMOS",
+ * "name": "shop-api",
+ * "ownerRole": "BACKEND"
+ * }
+ */
+ "application/json": {
+ /** @description GitHub 조직 login (GET /orgs/{orgId}/github/orgs) */
+ githubOrg: string;
+ /** @description 영숫자와 . _ - 만, 100자까지 */
+ name: string;
+ description?: string;
+ /** @enum {string} */
+ ownerRole: "FRONTEND" | "BACKEND";
+ };
+ };
+ };
+ responses: {
+ /** @description 만들고 연결함 */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "data": {
+ * "repo": {
+ * "id": "{{REPO_API}}",
+ * "fullName": "Capstone-NOMOS/shop-api",
+ * "seededPathCount": 15,
+ * "rootOwnerRole": "BACKEND",
+ * "githubRepoId": 123456,
+ * "defaultBranch": "main",
+ * "cloneUrl": "https://github.com/Capstone-NOMOS/shop-api"
+ * }
+ * }
+ * }
+ */
+ "application/json": {
+ data: {
+ repo: components["schemas"]["CreatedGithubRepo"];
+ };
+ };
+ };
+ };
+ /** @description 대표가 아니거나 GitHub가 거부(그 조직에 레포를 만들 권한이 없음, 조직이 NOMOS 앱을 승인하지 않음) */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ /** @description GitHub 미연결 또는 같은 이름의 레포가 이미 있음 */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/orgs/{orgId}/github/orgs": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 레포를 만들 수 있는 GitHub 조직 목록 (대표 전용)
+ * @description 대표 본인의 GitHub 토큰으로 대표가 속한 GitHub 조직을 읽는다. 레포 만들기 화면의 드롭다운을 채운다.
+ * GitHub를 연결하지 않았으면 409 `GITHUB_NOT_LINKED`.
+ *
+ * GitHub 조직이 OAuth 앱 접근 제한을 켜 두었으면 NOMOS 앱을 그 조직에서 승인해야 목록·생성이 된다.
+ */
+ get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @example {{ORG_ID}} */
+ orgId: components["parameters"]["OrgId"];
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description GitHub 조직 login 목록 */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "data": {
+ * "orgs": [
+ * "Capstone-NOMOS"
+ * ]
+ * }
+ * }
+ */
+ "application/json": {
+ data: {
+ orgs: string[];
+ };
+ };
+ };
+ };
+ /** @description 대표가 아님 */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "error": {
+ * "code": "NOT_REPRESENTATIVE",
+ * "message": "this action requires the organization representative"
+ * }
+ * }
+ */
+ "application/json": unknown;
+ };
+ };
+ /** @description 대표가 GitHub를 연결하지 않음 */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "error": {
+ * "code": "GITHUB_NOT_LINKED",
+ * "message": "GitHub 계정이 연결되어 있지 않습니다"
+ * }
+ * }
+ */
+ "application/json": unknown;
+ };
+ };
+ };
+ };
+ put?: never;
post?: never;
delete?: never;
options?: never;
@@ -1035,7 +1216,7 @@ export interface paths {
};
/**
* 조직 멤버 목록
- * @description GitHub collaborator 조회가 실패하면 `isCollaborator` 필드를 **생략**한다.
+ * @description 협업자 여부는 그 조직 대표의 GitHub 토큰으로 확인한다. 대표가 GitHub를 연결하지 않았거나 조회가 실패하면 `isCollaborator` 필드를 **생략**한다.
* `false`로 채우지 않는다 — "확인 안 됨"과 "권한 없음"은 다르다.
*/
get: {
@@ -2840,6 +3021,8 @@ export interface paths {
/** @example 2026-12-31 */
deadline?: string;
repoIds: string[];
+ /** @description 질문 중계. 생략하면 켜짐 */
+ questionRelay?: boolean;
};
};
};
@@ -3017,7 +3200,8 @@ export interface paths {
* {
* "agentId": "{{AGENT_BE}}",
* "agentName": "be-laptop",
- * "teamRole": "BACKEND"
+ * "teamRole": "BACKEND",
+ * "githubInvites": []
* }
* ]
* }
@@ -3072,6 +3256,79 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/projects/{projectId}/settings": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ /**
+ * 프로젝트 설정 (대표 전용, 시작 전에만)
+ * @description 지금은 질문 중계 스위치(`questionRelay`)뿐이다. **시작(G1) 뒤에는 403 `PROJECT_STARTED`** — 실행 중에 바뀌면 같은 프로젝트 안에서 결과가 섞인다.
+ * 바뀌면 이벤트 `PROJECT_SETTINGS_UPDATED`(before·after). 같은 값이면 그대로 돌려준다.
+ */
+ patch: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @example {{PROJECT_ID}} */
+ projectId: components["parameters"]["ProjectId"];
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ /**
+ * @example {
+ * "questionRelay": false
+ * }
+ */
+ "application/json": {
+ questionRelay: boolean;
+ };
+ };
+ };
+ responses: {
+ /** @description 바뀐 프로젝트 */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ data: components["schemas"]["Project"];
+ };
+ };
+ };
+ /** @description 대표가 아니거나 시작한 프로젝트 */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "error": {
+ * "code": "PROJECT_STARTED",
+ * "message": "project has started; settings are frozen"
+ * }
+ * }
+ */
+ "application/json": unknown;
+ };
+ };
+ };
+ };
+ trace?: never;
+ };
"/projects/{projectId}/start": {
parameters: {
query?: never;
@@ -3520,177 +3777,164 @@ export interface paths {
patch?: never;
trace?: never;
};
- "/projects/{projectId}/events": {
+ "/tasks/{taskId}/questions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
+ get?: never;
+ put?: never;
/**
- * 이벤트 로그 (최신순)
- * @description 프로젝트에서 일어난 일을 최신순으로 준다(활동 화면·대시보드 최근 활동). events는 지워지지 않는 기록이라 그대로 읽는다.
- * 볼 수 있는 범위는 태스크·노트와 같다 — 대표는 전부, 팀원은 자기 에이전트가 배정된 프로젝트.
- *
- * - 페이지: 응답의 `nextBefore`를 다음 요청의 `before`로 넘긴다. null이면 끝이다. 시각이 아니라 id로 넘긴다(같은 순간의 이벤트가 경계에서 빠지지 않게).
- * - `types`로 거른다(쉼표로 여러 개). `payload` 모양은 `type`마다 다르다.
- * - `onBehalfOf`는 사람(user id)이거나 시스템 주체 문자열(`system:pm`, `system:device-flow` 등)이다.
- * - `tokenCost`는 그 행의 비용(USD) — 지금은 PM 호출(`PM_CALL`)에만 있다. 태스크별·사람별 비용 집계는 별도 API로 다룬다.
+ * 질문 올리기 (에이전트 — 브릿지가 대신 부른다, 실험)
+ * @description 실행 중인 에이전트가 AskUserQuestion으로 물으면 브릿지의 권한 도구가 여기에 올리고, 답이 오거나 만료될 때까지 기다린다.
+ * 대상 역할은 서버가 정한다(지금은 묻는 쪽의 반대 역할). 그 태스크의 담당 에이전트만 물을 수 있다.
*/
- get: {
+ post: {
parameters: {
- query?: {
- /** @description 이 id보다 오래된 이벤트만 */
- before?: string;
- limit?: number;
- /** @example TASK_CLAIMED,NOTE_PUBLISHED */
- types?: string;
- };
+ query?: never;
header?: never;
path: {
- /** @example {{PROJECT_ID}} */
- projectId: components["parameters"]["ProjectId"];
+ taskId: string;
};
cookie?: never;
};
- requestBody?: never;
+ requestBody: {
+ content: {
+ /**
+ * @example {
+ * "questions": [
+ * {
+ * "question": "GET /api/studies/:id/members 의 응답 본문 형태는 무엇인가요?",
+ * "header": "응답 형태",
+ * "multiSelect": false,
+ * "options": [
+ * {
+ * "label": "{ members: [...] }"
+ * },
+ * {
+ * "label": "배열 그대로"
+ * }
+ * ]
+ * }
+ * ]
+ * }
+ */
+ "application/json": {
+ questions: {
+ [key: string]: unknown;
+ }[];
+ };
+ };
+ };
responses: {
- /** @description 최신순 */
- 200: {
+ /** @description 올라감(status=pending) */
+ 201: {
headers: {
[name: string]: unknown;
};
content: {
- /**
- * @example {
- * "data": {
- * "events": [
- * {
- * "id": "1042",
- * "type": "NOTE_PUBLISHED",
- * "ts": "2026-10-02T00:58:05.640Z",
- * "onBehalfOf": "e8227252-a58b-4266-839b-8674f99b1e4a",
- * "actorAgentId": "e6c879be-2a69-4f82-a30d-5063ff664262",
- * "payload": {
- * "noteId": "5c457d82-8804-4408-a76e-3f5a355b8484",
- * "kind": "DECIDED"
- * },
- * "tokenCost": null,
- * "pathViolation": null
- * }
- * ],
- * "nextBefore": null
- * }
- * }
- */
"application/json": {
- data: {
- events: components["schemas"]["ProjectEvent"][];
- /** @description 다음 페이지의 before. null이면 끝 */
- nextBefore: string | null;
- };
+ data: components["schemas"]["AgentQuestion"];
};
};
};
+ /** @description 질문 형식 위반(개수·길이·중복 질문) — error.details에 전부 */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
};
};
- put?: never;
- post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
- "/projects/{projectId}/members": {
+ "/tasks/{taskId}/questions/{questionId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
- get?: never;
- put?: never;
/**
- * 역할 배정 (대표 전용)
- * @description **배정 뒤 그 에이전트는 토큰을 재발급해야 한다.** 배정 전에 발급된 토큰에는 `project_id`가 없어
- * 태스크·노트 API가 403 `NOT_PROJECT_MEMBER`로 막힌다. `POST /agents/token/refresh`를 한 번 부르면 된다.
- * 응답의 `notice`에도 같은 안내가 들어 있다.
- *
- * 프로젝트당 역할은 **하나씩만** 배정된다(`uq_project_members_role`). 실험 통제를 위해서다.
- * **G1(`started_at`) 이후에는 멤버를 바꿀 수 없다** — 도중에 바뀌면 통제가 흔들린다.
+ * 질문 상태 (묻은 에이전트 — 브릿지가 답을 기다리며 읽는다, 실험)
+ * @description 만료 시각이 지났으면 이 조회에서 expired로 바뀐다.
*/
- post: {
+ get: {
parameters: {
query?: never;
header?: never;
path: {
- /** @example {{PROJECT_ID}} */
- projectId: components["parameters"]["ProjectId"];
+ taskId: string;
+ questionId: string;
};
cookie?: never;
};
- requestBody: {
- content: {
- /**
- * @example {
- * "agentId": "{{AGENT_BE}}",
- * "teamRole": "BACKEND"
- * }
- */
- "application/json": {
- /** Format: uuid */
- agentId: string;
- /** @enum {string} */
- teamRole: "FRONTEND" | "BACKEND";
- };
- };
- };
+ requestBody?: never;
responses: {
- /** @description 배정됨. 남은 멤버 목록과 재발급 안내를 함께 준다. */
- 201: {
+ /** @description 질문 */
+ 200: {
headers: {
[name: string]: unknown;
};
content: {
- /**
- * @example {
- * "data": {
- * "members": [
- * {
- * "agentId": "{{AGENT_BE}}",
- * "agentName": "be-laptop",
- * "teamRole": "BACKEND"
- * }
- * ],
- * "notice": "배정된 에이전트는 POST /api/agents/token/refresh로 토큰을 재발급해야 태스크·노트 API를 쓸 수 있습니다."
- * }
- * }
- */
"application/json": {
- data: {
- members: components["schemas"]["ProjectMember"][];
- notice: string;
- };
+ data: components["schemas"]["AgentQuestion"];
};
};
};
- /** @description 대표가 아님 · 다른 조직의 에이전트 · G1 이후 */
- 403: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": unknown;
- };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/tasks/{taskId}/questions/{questionId}/detach": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 기다림 포기 — 태스크를 내려놓는다 (에이전트 — 브릿지가 부른다, 실험)
+ * @description 브릿지가 정한 시간(기본 3분)만 기다렸는데 답이 없으면 부른다. 태스크가 BLOCKED(QUESTION)가 되고 담당이 비워진다 —
+ * 에이전트 실행은 커밋 없이 끝나고 노트북은 다른 태스크로 간다. 답이 오면 READY로 돌아가 같은 브랜치에서 이어 간다.
+ * 그 사이 답이 왔으면 아무것도 바꾸지 않고 질문을 돌려준다(status로 판단).
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ taskId: string;
+ questionId: string;
};
- /** @description 역할이 이미 찼거나, 그 에이전트가 이미 배정됐거나, 다른 진행 중 프로젝트를 맡고 있음 */
- 409: {
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description 질문(지금 상태) */
+ 200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": unknown;
+ "application/json": {
+ data: components["schemas"]["AgentQuestion"];
+ };
};
};
};
@@ -3701,97 +3945,115 @@ export interface paths {
patch?: never;
trace?: never;
};
- "/projects/{projectId}/members/{agentId}": {
+ "/agents/me/questions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
- get?: never;
- put?: never;
- post?: never;
/**
- * 배정 해제 (대표 전용)
- * @description **역할 교체는 해제 후 재배정으로 한다.** UPDATE 경로를 두면 한 역할에 둘이 잠깐 겹칠 수 있다.
- * G1(`started_at`) 이후에는 해제도 막힌다.
+ * 상담할 질문 (대상 역할 에이전트의 Executor, 실험)
+ * @description 이 에이전트의 역할을 대상으로 한, 아직 초안이 없는 대기 질문과 상담 실행이 읽을 레포(이 역할이 소유한 경로가 있는 레포).
*/
- delete: {
+ get: {
parameters: {
query?: never;
header?: never;
- path: {
- /** @example {{PROJECT_ID}} */
- projectId: components["parameters"]["ProjectId"];
- /** @example {{AGENT_BE}} */
- agentId: string;
- };
+ path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
- /** @description 해제됨. 남은 멤버 목록을 준다. */
+ /** @description 질문·레포 */
200: {
headers: {
[name: string]: unknown;
};
content: {
- /**
- * @example {
- * "data": {
- * "members": []
- * }
- * }
- */
"application/json": {
data: {
- members: components["schemas"]["ProjectMember"][];
+ questions: components["schemas"]["AgentQuestion"][];
+ repos: {
+ /** Format: uuid */
+ id: string;
+ fullName: string;
+ cloneUrl: string | null;
+ defaultBranch: string;
+ }[];
};
};
};
};
- /** @description 대표가 아니거나 G1 이후 */
- 403: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- /**
- * @example {
- * "error": {
- * "code": "PROJECT_STARTED",
- * "message": "project has started; members are frozen"
- * }
- * }
- */
- "application/json": unknown;
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/questions/{questionId}/draft": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 상담 초안 올리기 (대상 역할 에이전트, 실험)
+ * @description 읽기 전용 상담 실행이 코드·명세를 보고 만든 답. **질문 전부가 decided=true면 초안이 곧 답이다**(agent_answered — 묻는 쪽에 바로 간다,
+ * 사람은 나중에 확인하거나 뒤집는다). 하나라도 false면 초안은 참고로만 남고 대상 역할의 사람이 답한다. 초안은 한 번만 받는다.
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ questionId: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ answers: {
+ [key: string]: string;
+ };
+ decided: {
+ [key: string]: boolean;
+ };
+ basis?: {
+ [key: string]: string[];
+ };
};
};
- /** @description 배정되지 않은 에이전트 */
- 404: {
+ };
+ responses: {
+ /** @description 저장됨(전부 decided면 status=agent_answered) */
+ 200: {
headers: {
[name: string]: unknown;
};
content: {
- /**
- * @example {
- * "error": {
- * "code": "MEMBER_NOT_FOUND",
- * "message": "agent is not a member of this project"
- * }
- * }
- */
- "application/json": unknown;
+ "application/json": {
+ data: components["schemas"]["AgentQuestion"];
+ };
};
};
};
};
+ delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
- "/agents/me": {
+ "/me/questions": {
parameters: {
query?: never;
header?: never;
@@ -3799,37 +4061,37 @@ export interface paths {
cookie?: never;
};
/**
- * 에이전트 자기 설정
- * @description Executor가 기동할 때 자기 프로젝트·역할·동시 실행 상한을 읽는다. 이 값들을 Executor 쪽 설정
- * 파일에 적어두면 서버에서 바꿔도 반영되지 않으므로, 서버가 정본을 준다.
+ * 내가 답할 질문 (조직 전체, 실험)
+ * @description 알림 배지와 "답할 질문" 목록용. **대표는 조직의 모든 질문, 팀원은 자기 에이전트가 맡은 역할을 대상으로 한 질문만**(답할 수 있는 사람과 같은 정의).
+ * 기본은 대기(pending) — 사람이 답해야 하는 것. `agent_answered`는 상대 에이전트가 코드에서 찾아 이미 답한 것(사람이 확인·뒤집기 가능).
+ * 실시간: `/api/stream`의 조직 토픽 `questions`가 오면 다시 읽는다.
*/
get: {
parameters: {
- query?: never;
+ query?: {
+ status?: "pending" | "agent_answered" | "answered" | "expired" | "self_owned" | "all";
+ limit?: number;
+ };
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
- /** @description OK */
+ /** @description 최신순. 질문과 그 프로젝트·태스크 이름 */
200: {
headers: {
[name: string]: unknown;
};
content: {
- /**
- * @example {
- * "data": {
- * "agentId": "2012e1df-0beb-43fd-ac09-d893ba56b01e",
- * "projectId": "{{PROJECT_ID}}",
- * "teamRole": "BACKEND",
- * "maxConcurrent": 2
- * }
- * }
- */
"application/json": {
- data: components["schemas"]["AgentSelf"];
+ data: {
+ questions: {
+ question: components["schemas"]["AgentQuestion"];
+ projectName: string;
+ taskTitle: string;
+ }[];
+ };
};
};
};
@@ -3843,17 +4105,23 @@ export interface paths {
patch?: never;
trace?: never;
};
- "/projects/{projectId}/pm/plans": {
+ "/projects/{projectId}/questions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
- /** PM 계획 요청 이력 (대표 전용) */
+ /**
+ * 프로젝트의 에이전트 질문 (실험)
+ * @description 답할 사람이 보는 목록. 볼 수 있는 범위는 태스크와 같다. 실시간 신호 토픽은 questions.
+ */
get: {
parameters: {
- query?: never;
+ query?: {
+ status?: "pending" | "agent_answered" | "answered" | "expired" | "self_owned" | "all";
+ limit?: number;
+ };
header?: never;
path: {
/** @example {{PROJECT_ID}} */
@@ -3863,22 +4131,15 @@ export interface paths {
};
requestBody?: never;
responses: {
- /** @description 최근 요청 순 */
+ /** @description 최신순 */
200: {
headers: {
[name: string]: unknown;
};
content: {
- /**
- * @example {
- * "data": {
- * "plans": []
- * }
- * }
- */
"application/json": {
data: {
- plans: components["schemas"]["PmPlan"][];
+ questions: components["schemas"]["AgentQuestion"][];
};
};
};
@@ -3886,24 +4147,35 @@ export interface paths {
};
};
put?: never;
- /**
- * 계획 초안 요청 (대표 전용)
- * @description 대표의 지시로 PM(NOMOS 내장)이 명세(API·화면 계약 + 수용 기준)와 태스크 DAG(어느 레포·어느 역할이 무엇을 어떤 순서로) 초안을 만든다.
- * **202로 바로 답하고 PM은 뒤에서 돈다**(수십 초~수 분). `GET .../pm/plans/{planId}`를 몇 초 간격으로 폴링해 `ready`·`failed`를 본다.
- *
- * - 프로젝트당 진행 중인 요청은 하나(409 `PM_PLAN_IN_PROGRESS`)
- * - 호출 전에 "누적 PM 비용 + 이번 호출의 최대치 > PM 예산"이면 409 `PM_BUDGET_EXCEEDED` —
- * 설계상 예산 초과는 대표 승인 카드가 떠야 하지만 **승인 경로 미구현**이라 거절만 한다
- * - 서버에 PM 키가 없으면 503 `PM_UNAVAILABLE`
- * - 초안은 명세·태스크 생성과 같은 코드 검증을 거친다. 틀리면 PM이 **한 번만** 고치고, 그래도 틀리면 `failed(invalid)`
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/questions/{questionId}/answer": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 질문에 답하기·확인·뒤집기 (대상 역할 담당 또는 대표, 실험)
+ * @description 대기 중(pending)이면 직접 답한다. 에이전트가 이미 답했으면(agent_answered) 같은 답을 보내면 확인, 다른 답이면 뒤집기다
+ * (뒤집으면 묻는 쪽이 이미 그 답으로 구현했을 수 있다 — 고치는 태스크 자동 생성은 아직 없다).
+ * 키는 질문 문장 **그대로**여야 한다(다르면 에이전트가 "답하지 않음"으로 받는다 — 그래서 422로 막는다).
+ * 값은 선택지 label이거나 자유 문장. 근거를 덧붙이면 에이전트가 그것까지 따른다.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
- /** @example {{PROJECT_ID}} */
- projectId: components["parameters"]["ProjectId"];
+ questionId: string;
};
cookie?: never;
};
@@ -3911,82 +4183,142 @@ export interface paths {
content: {
/**
* @example {
- * "instruction": "3주 안에 사내 스터디 관리 웹앱. 회원가입, 스터디 개설, 참여신청, 출석체크. 모바일 대응 필수."
+ * "answers": {
+ * "GET /api/studies/:id/members 의 응답 본문 형태는 무엇인가요?": "{ members: [...] } — 백엔드 공통 목록 형식"
+ * }
* }
*/
"application/json": {
- instruction: string;
+ answers: {
+ [key: string]: string;
+ };
};
};
};
responses: {
- /** @description 접수됨(status=pending) */
- 202: {
+ /** @description 답함(status=answered). 기다리던 에이전트가 바로 이어서 작업한다 */
+ 200: {
headers: {
[name: string]: unknown;
};
content: {
- /**
- * @example {
- * "data": {
- * "id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d",
- * "projectId": "{{PROJECT_ID}}",
- * "status": "pending",
- * "instruction": "3주 안에 …",
- * "feedback": null,
- * "parentPlanId": null,
- * "mode": null,
- * "draft": null,
- * "error": null,
- * "costUsd": 0,
- * "createdAt": "2026-09-30T02:00:00.000Z",
- * "appliedAt": null,
- * "rejectedAt": null,
- * "rejectReason": null,
- * "assignments": []
- * }
- * }
- */
"application/json": {
- data: components["schemas"]["PmPlan"];
+ data: components["schemas"]["AgentQuestion"];
};
};
};
- /** @description 진행 중인 요청 있음 · 예산 초과 · 끝난 프로젝트 */
+ /** @description 대상 역할 담당도 대표도 아님(NOT_QUESTION_TARGET) */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description 이미 답했거나 만료됨(QUESTION_CLOSED) */
409: {
headers: {
[name: string]: unknown;
};
- content: {
- "application/json": unknown;
+ content?: never;
+ };
+ /** @description 키가 질문과 다르거나 답이 비었음(QUESTION_INVALID) — error.details에 전부 */
+ 422: {
+ headers: {
+ [name: string]: unknown;
};
+ content?: never;
};
- /** @description 서버에 PM 키(ANTHROPIC_API_KEY)가 없다 */
- 503: {
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/projects/{projectId}/events": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 이벤트 로그 (최신순)
+ * @description 프로젝트에서 일어난 일을 최신순으로 준다(활동 화면·대시보드 최근 활동). events는 지워지지 않는 기록이라 그대로 읽는다.
+ * 볼 수 있는 범위는 태스크·노트와 같다 — 대표는 전부, 팀원은 자기 에이전트가 배정된 프로젝트.
+ *
+ * - 페이지: 응답의 `nextBefore`를 다음 요청의 `before`로 넘긴다. null이면 끝이다. 시각이 아니라 id로 넘긴다(같은 순간의 이벤트가 경계에서 빠지지 않게).
+ * - `types`로 거른다(쉼표로 여러 개). `payload` 모양은 `type`마다 다르다.
+ * - `onBehalfOf`는 사람(user id)이거나 시스템 주체 문자열(`system:pm`, `system:device-flow` 등)이다.
+ * - `tokenCost`는 그 행의 비용(USD) — 지금은 PM 호출(`PM_CALL`)에만 있다. 태스크별·사람별 비용 집계는 별도 API로 다룬다.
+ */
+ get: {
+ parameters: {
+ query?: {
+ /** @description 이 id보다 오래된 이벤트만 */
+ before?: string;
+ limit?: number;
+ /** @example TASK_CLAIMED,NOTE_PUBLISHED */
+ types?: string;
+ };
+ header?: never;
+ path: {
+ /** @example {{PROJECT_ID}} */
+ projectId: components["parameters"]["ProjectId"];
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description 최신순 */
+ 200: {
headers: {
[name: string]: unknown;
};
content: {
/**
* @example {
- * "error": {
- * "code": "PM_UNAVAILABLE",
- * "message": "the built-in PM is not configured on this server (ANTHROPIC_API_KEY)"
+ * "data": {
+ * "events": [
+ * {
+ * "id": "1042",
+ * "type": "NOTE_PUBLISHED",
+ * "ts": "2026-10-02T00:58:05.640Z",
+ * "onBehalfOf": "e8227252-a58b-4266-839b-8674f99b1e4a",
+ * "actorAgentId": "e6c879be-2a69-4f82-a30d-5063ff664262",
+ * "payload": {
+ * "noteId": "5c457d82-8804-4408-a76e-3f5a355b8484",
+ * "kind": "DECIDED"
+ * },
+ * "tokenCost": null,
+ * "pathViolation": null
+ * }
+ * ],
+ * "nextBefore": null
* }
* }
*/
- "application/json": unknown;
+ "application/json": {
+ data: {
+ events: components["schemas"]["ProjectEvent"][];
+ /** @description 다음 페이지의 before. null이면 끝 */
+ nextBefore: string | null;
+ };
+ };
};
};
};
};
+ put?: never;
+ post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
- "/projects/{projectId}/pm/status": {
+ "/projects/{projectId}/rooms": {
parameters: {
query?: never;
header?: never;
@@ -3994,13 +4326,9 @@ export interface paths {
cookie?: never;
};
/**
- * PM 준비 상태 (대표 전용)
- * @description "계획 받기" 버튼을 켜기 전에 본다. **요청 자체를 막지는 않는다.**
- * - `ready: false`, `reason: NO_API_KEY` — API 모드인데 서버에 키가 없다. 요청하면 503.
- * - `ready: false`, `reason: WORKER_OFFLINE` — 중계 모드인데 대표 노트북의 pm-worker가 30초 넘게 작업을 확인하지 않았다(꺼져 있다).
- * 요청하면 받아지지만 아무도 가져가지 않아 `PM_TIMEOUT_MS`(기본 10분) 뒤 `failed(timeout)`이 된다.
- * pm-worker는 3초마다 확인하므로 켜면 몇 초 안에 `ready: true`가 된다.
- * - `pendingPlanId`가 있으면 작성 중인 계획이 있어 새 요청은 409다.
+ * 내가 볼 수 있는 룸 목록
+ * @description 룸 = 프로젝트 × 역할. **팀원은 자기 에이전트가 배정된 역할 룸만, 대표는 전부** 본다.
+ * 룸마다 담당 에이전트(온라인 여부)와 진행 중 태스크를 준다 — 룸 화면 머리.
*/
get: {
parameters: {
@@ -4014,41 +4342,15 @@ export interface paths {
};
requestBody?: never;
responses: {
- /** @description 상태 */
+ /** @description 룸 목록(FRONTEND, BACKEND 순) */
200: {
headers: {
[name: string]: unknown;
};
content: {
- /**
- * @example {
- * "data": {
- * "provider": "relay",
- * "ready": false,
- * "reason": "WORKER_OFFLINE",
- * "workerLastSeenAt": null,
- * "budgetUsd": 5,
- * "spentUsd": 0.1077,
- * "pendingPlanId": null
- * }
- * }
- */
"application/json": {
data: {
- /** @enum {string} */
- provider: "api" | "relay";
- ready: boolean;
- /** @enum {string|null} */
- reason: "NO_API_KEY" | "WORKER_OFFLINE" | null;
- /**
- * Format: date-time
- * @description 중계 모드에서 pm-worker가 마지막으로 작업을 확인한 시각
- */
- workerLastSeenAt: string | null;
- budgetUsd: number;
- spentUsd: number;
- /** Format: uuid */
- pendingPlanId: string | null;
+ rooms: components["schemas"]["RoomSummary"][];
};
};
};
@@ -4063,7 +4365,7 @@ export interface paths {
patch?: never;
trace?: never;
};
- "/projects/{projectId}/pm/plans/{planId}": {
+ "/projects/{projectId}/rooms/{role}/feed": {
parameters: {
query?: never;
header?: never;
@@ -4071,66 +4373,60 @@ export interface paths {
cookie?: never;
};
/**
- * 계획 상태·초안 (대표 전용)
- * @description `pending`이면 작성 중이다. `ready`면 `draft`를 검토한다 — 명세의 계약과 태스크별 담당 역할·선행 관계.
- * `failed`면 `error.reason`으로 이유를 보여준다.
+ * 룸 피드 (최신부터)
+ * @description 서버(PM)의 지시·판정, Executor의 실행 상태, 에이전트의 도구 사용을 시각순으로 섞은 한 줄씩. **문장(`text`)은 서버가 만든다** — 화면은 그대로 보여 주면 된다.
+ *
+ * 예(오래된 것부터):
+ * ```
+ * [pm] T-3 회원가입 API 실행해 주세요
+ * [nomos] 에이전트를 실행합니다
+ * [nomos] 태스크 수령 — T-3 회원가입 API · 구현을 시작합니다
+ * [agent] src/routes/auth.ts 읽는 중
+ * [agent] src/routes/auth.ts 수정
+ * [agent] npm test 실행
+ * [nomos] 제출했습니다 (커밋 a1b2c3d, 파일 3개)
+ * [pm] 검증 완료 — V3 통과, V1A·V1B·V2·V4 건너뜀 → 완료되었습니다
+ * [nomos] 실행을 마쳤습니다 (240초)
+ * ```
+ * 태스크에 딸린 줄은 그 태스크의 역할 룸에만(역할 제한 없는 태스크는 모든 룸), 프로젝트 단위 PM 알림(시작·계획 적용)은 모든 룸에 나온다.
+ * 응답은 **최신부터**이고, `nextBefore`를 `before`로 넘기면 더 오래된 줄을 읽는다(없으면 null).
+ * 실시간: `/api/stream`에서 그 프로젝트를 구독하면 `room` 토픽이 올 때 다시 읽는다.
+ * 팀원이 다른 역할 룸을 읽으면 403 `ROOM_NOT_VISIBLE`.
*/
get: {
parameters: {
- query?: never;
+ query?: {
+ /** @description 이전 응답의 nextBefore */
+ before?: string;
+ limit?: number;
+ };
header?: never;
path: {
/** @example {{PROJECT_ID}} */
projectId: components["parameters"]["ProjectId"];
- planId: string;
+ /** @example BACKEND */
+ role: "FRONTEND" | "BACKEND";
};
cookie?: never;
};
requestBody?: never;
responses: {
- /** @description 계획 */
+ /** @description 피드 */
200: {
headers: {
[name: string]: unknown;
};
content: {
- /**
- * @example {
- * "data": {
- * "id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d",
- * "projectId": "{{PROJECT_ID}}",
- * "status": "ready",
- * "instruction": "3주 안에 …",
- * "feedback": null,
- * "parentPlanId": null,
- * "mode": "SEQUENTIAL",
- * "draft": {
- * "mode": "SEQUENTIAL",
- * "rationale": "요구사항이 작고 명확하다",
- * "estimate": {
- * "workingDays": 10,
- * "notes": ""
- * },
- * "specs": [],
- * "tasks": []
- * },
- * "error": null,
- * "costUsd": 0.4213,
- * "createdAt": "2026-09-30T02:00:00.000Z",
- * "appliedAt": null,
- * "rejectedAt": null,
- * "rejectReason": null,
- * "assignments": []
- * }
- * }
- */
"application/json": {
- data: components["schemas"]["PmPlan"];
+ data: {
+ messages: components["schemas"]["RoomMessage"][];
+ nextBefore: string | null;
+ };
};
};
};
- /** @description 없는 계획 */
- 404: {
+ /** @description 자기 역할이 아닌 룸 */
+ 403: {
headers: {
[name: string]: unknown;
};
@@ -4138,8 +4434,8 @@ export interface paths {
/**
* @example {
* "error": {
- * "code": "PLAN_NOT_FOUND",
- * "message": "plan not found"
+ * "code": "ROOM_NOT_VISIBLE",
+ * "message": "you cannot see the BACKEND room of this project"
* }
* }
*/
@@ -4156,7 +4452,7 @@ export interface paths {
patch?: never;
trace?: never;
};
- "/projects/{projectId}/pm/plans/{planId}/revise": {
+ "/projects/{projectId}/members": {
parameters: {
query?: never;
header?: never;
@@ -4166,12 +4462,18 @@ export interface paths {
get?: never;
put?: never;
/**
- * 수정 요청 (대표 전용)
- * @description `ready`인 초안에 피드백을 붙여 새 초안을 요청한다(이전 초안 + 피드백으로 새로 쓴다). 새 계획이 pending으로 생기고
- * 이전 초안은 그대로 남는다. 한 수정 체인에서는 하나만 적용할 수 있다.
+ * 역할 배정 (대표 전용)
+ * @description **배정 뒤 그 에이전트는 토큰을 재발급해야 한다.** 배정 전에 발급된 토큰에는 `project_id`가 없어
+ * 태스크·노트 API가 403 `NOT_PROJECT_MEMBER`로 막힌다. `POST /agents/token/refresh`를 한 번 부르면 된다.
+ * 응답의 `notice`에도 같은 안내가 들어 있다.
*
- * **수정 요청은 한 체인에서 3회까지**(서버 설정 `PM_MAX_REVISIONS`, 기본 3). 원본 요청은 세지 않고, 실패한 수정 요청도 센다.
- * 넘으면 409 `PLAN_REVISION_LIMIT`, `details: { limit, used }`. 새 계획 요청(`POST …/pm/plans`)은 새 체인이라 0부터 다시 센다.
+ * 프로젝트당 역할은 **하나씩만** 배정된다(`uq_project_members_role`). 실험 통제를 위해서다.
+ * **G1(`started_at`) 이후에는 멤버를 바꿀 수 없다** — 도중에 바뀌면 통제가 흔들린다.
+ *
+ * **GitHub 협업자 자동 초대**: 배정과 함께 그 에이전트 주인을 프로젝트의 GitHub 레포(`githubRepoId`가 있는 것)마다
+ * push 권한 협업자로 초대한다(대표의 GitHub 토큰). 결과는 `githubInvites`(레포마다 한 줄). 초대가 실패해도 배정은 유효하다 —
+ * 멤버가 GitHub를 연결하지 않았으면 `skipped`이고, 연결한 뒤 `POST .../members/{agentId}/github-invite`로 다시 보낸다.
+ * GitHub 레포가 없는 프로젝트(로컬 데모)는 빈 배열이다.
*/
post: {
parameters: {
@@ -4180,7 +4482,6 @@ export interface paths {
path: {
/** @example {{PROJECT_ID}} */
projectId: components["parameters"]["ProjectId"];
- planId: string;
};
cookie?: never;
};
@@ -4188,17 +4489,21 @@ export interface paths {
content: {
/**
* @example {
- * "feedback": "출석체크는 다음 단계로 미루고, 스터디 개설에 정원 설정을 넣어줘"
+ * "agentId": "{{AGENT_BE}}",
+ * "teamRole": "BACKEND"
* }
*/
"application/json": {
- feedback: string;
+ /** Format: uuid */
+ agentId: string;
+ /** @enum {string} */
+ teamRole: "FRONTEND" | "BACKEND";
};
};
};
responses: {
- /** @description 새 초안 접수됨(status=pending) */
- 202: {
+ /** @description 배정됨. 남은 멤버 목록과 재발급 안내를 함께 준다. */
+ 201: {
headers: {
[name: string]: unknown;
};
@@ -4206,30 +4511,44 @@ export interface paths {
/**
* @example {
* "data": {
- * "id": "2c3d4e5f-6071-4b2c-9d3e-4f5a6b7c8d9e",
- * "projectId": "{{PROJECT_ID}}",
- * "status": "pending",
- * "instruction": "3주 안에 …",
- * "feedback": "출석체크는 다음 단계로",
- * "parentPlanId": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d",
- * "mode": null,
- * "draft": null,
- * "error": null,
- * "costUsd": 0,
- * "createdAt": "2026-09-30T02:05:00.000Z",
- * "appliedAt": null,
- * "rejectedAt": null,
- * "rejectReason": null,
- * "assignments": []
+ * "members": [
+ * {
+ * "agentId": "{{AGENT_BE}}",
+ * "agentName": "be-laptop",
+ * "teamRole": "BACKEND",
+ * "githubInvites": []
+ * }
+ * ],
+ * "githubInvites": [
+ * {
+ * "repoId": "{{REPO_API}}",
+ * "fullName": "acme/study-api",
+ * "status": "invited"
+ * }
+ * ],
+ * "notice": "배정된 에이전트는 POST /api/agents/token/refresh로 토큰을 재발급해야 태스크·노트 API를 쓸 수 있습니다."
* }
* }
*/
"application/json": {
- data: components["schemas"]["PmPlan"];
+ data: {
+ members: components["schemas"]["ProjectMember"][];
+ githubInvites: components["schemas"]["GithubInviteResult"][];
+ notice: string;
+ };
};
};
};
- /** @description ready가 아닌 초안, 또는 수정 요청 한도 초과 */
+ /** @description 대표가 아님 · 다른 조직의 에이전트 · G1 이후 */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ /** @description 역할이 이미 찼거나, 그 에이전트가 이미 배정됐거나, 다른 진행 중 프로젝트를 맡고 있음 */
409: {
headers: {
[name: string]: unknown;
@@ -4246,7 +4565,7 @@ export interface paths {
patch?: never;
trace?: never;
};
- "/projects/{projectId}/pm/plans/{planId}/apply": {
+ "/projects/{projectId}/members/{agentId}/github-invite": {
parameters: {
query?: never;
header?: never;
@@ -4256,12 +4575,9 @@ export interface paths {
get?: never;
put?: never;
/**
- * 초안 적용 (대표 전용)
- * @description 저장된 초안을 **그대로** 명세·태스크로 만든다(명세·태스크 생성과 같은 검증·잠금, `source: pm`). 시험지는 만들지 않는다.
- * 태스크는 READY라 배정된 에이전트가 바로 수령할 수 있다. **G1(프로젝트 시작)이 아니다** — 프로젝트 상태는 그대로다.
- *
- * - 두 번 적용하거나 같은 수정 체인의 다른 초안을 적용하면 409 `PLAN_NOT_APPLICABLE`
- * - 초안 뒤에 상황이 바뀌어 검증에 걸리면 422 `PLAN_INVALID` — 아무것도 만들지 않고 계획은 `ready`로 남는다. 수정 요청으로 이어가면 된다
+ * GitHub 협업자 초대 다시 보내기 (대표 전용)
+ * @description 역할 배정 때 멤버가 GitHub를 연결하지 않았거나 GitHub 호출이 실패했을 때 다시 보낸다.
+ * 멤버 구성을 바꾸는 게 아니라 접근을 주는 일이라 **시작(G1) 뒤에도 된다.** 실패해도 200이고 결과는 `githubInvites`에 적힌다.
*/
post: {
parameters: {
@@ -4270,13 +4586,14 @@ export interface paths {
path: {
/** @example {{PROJECT_ID}} */
projectId: components["parameters"]["ProjectId"];
- planId: string;
+ /** @example {{AGENT_BE}} */
+ agentId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
- /** @description 적용됨(status=applied) */
+ /** @description 레포마다 초대 결과 */
200: {
headers: {
[name: string]: unknown;
@@ -4285,31 +4602,26 @@ export interface paths {
/**
* @example {
* "data": {
- * "id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d",
- * "projectId": "{{PROJECT_ID}}",
- * "status": "applied",
- * "instruction": "3주 안에 …",
- * "feedback": null,
- * "parentPlanId": null,
- * "mode": "SEQUENTIAL",
- * "draft": null,
- * "error": null,
- * "costUsd": 0.4213,
- * "createdAt": "2026-09-30T02:00:00.000Z",
- * "appliedAt": "2026-09-30T02:10:00.000Z",
- * "rejectedAt": null,
- * "rejectReason": null,
- * "assignments": []
+ * "githubInvites": [
+ * {
+ * "repoId": "{{REPO_API}}",
+ * "fullName": "acme/study-api",
+ * "status": "skipped",
+ * "reason": "멤버가 GitHub를 연결하지 않았다"
+ * }
+ * ]
* }
* }
*/
"application/json": {
- data: components["schemas"]["PmPlan"];
+ data: {
+ githubInvites: components["schemas"]["GithubInviteResult"][];
+ };
};
};
};
- /** @description 적용할 수 없는 상태 */
- 409: {
+ /** @description 대표가 아니거나 다른 조직 */
+ 403: {
headers: {
[name: string]: unknown;
};
@@ -4317,16 +4629,16 @@ export interface paths {
/**
* @example {
* "error": {
- * "code": "PLAN_NOT_APPLICABLE",
- * "message": "another plan in this revision chain has already been applied"
+ * "code": "NOT_REPRESENTATIVE",
+ * "message": "this action requires the organization representative"
* }
* }
*/
"application/json": unknown;
};
};
- /** @description 초안 이후 상황이 바뀌어 검증 실패(계획은 ready로 남는다) */
- 422: {
+ /** @description 이 프로젝트의 멤버가 아닌 에이전트 */
+ 404: {
headers: {
[name: string]: unknown;
};
@@ -4334,14 +4646,8 @@ export interface paths {
/**
* @example {
* "error": {
- * "code": "PLAN_INVALID",
- * "message": "명세·태스크 검증 실패 1건",
- * "details": [
- * {
- * "where": "tasks[api]",
- * "message": "태스크 \"T-1\"이 프로젝트에 이미 있다 (다시 보낸 건 아닌가?)"
- * }
- * ]
+ * "code": "MEMBER_NOT_FOUND",
+ * "message": "agent ... is not a member of this project"
* }
* }
*/
@@ -4356,32 +4662,36 @@ export interface paths {
patch?: never;
trace?: never;
};
- "/pm/jobs/next": {
+ "/projects/{projectId}/members/{agentId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
+ get?: never;
+ put?: never;
+ post?: never;
/**
- * (중계 모드) 다음 PM 작업 가져가기 — 대표의 pm-worker 전용
- * @description **서버가 `PM_PROVIDER=relay`일 때만 쓴다.** 결제 전까지 PM의 모델 호출을 대표 노트북의 Claude Code로 대신 돌리는 임시 방식이다 —
- * `npm run executor pm-worker`가 몇 초마다 부른다. 사람이 직접 부를 일은 없다.
- * API 모드(`PM_PROVIDER=api`, 기본)에서는 409 `PM_RELAY_DISABLED`.
- *
- * 조직 **대표 본인의** 에이전트만 가져간다(그 밖은 403 `NOT_REPRESENTATIVE`). 한 번 가져간 작업은 다시 나오지 않고, 없으면 `job: null`.
- * 아무도 가져가지 않으면 `PM_TIMEOUT_MS` 뒤 계획이 `failed(timeout)`가 된다.
+ * 배정 해제 (대표 전용)
+ * @description **역할 교체는 해제 후 재배정으로 한다.** UPDATE 경로를 두면 한 역할에 둘이 잠깐 겹칠 수 있다.
+ * G1(`started_at`) 이후에는 해제도 막힌다.
*/
- get: {
+ delete: {
parameters: {
query?: never;
header?: never;
- path?: never;
+ path: {
+ /** @example {{PROJECT_ID}} */
+ projectId: components["parameters"]["ProjectId"];
+ /** @example {{AGENT_BE}} */
+ agentId: string;
+ };
cookie?: never;
};
requestBody?: never;
responses: {
- /** @description 작업(없으면 null) */
+ /** @description 해제됨. 남은 멤버 목록을 준다. */
200: {
headers: {
[name: string]: unknown;
@@ -4390,19 +4700,19 @@ export interface paths {
/**
* @example {
* "data": {
- * "job": null
+ * "members": []
* }
* }
*/
"application/json": {
data: {
- job: components["schemas"]["PmRelayJob"];
+ members: components["schemas"]["ProjectMember"][];
};
};
};
};
- /** @description 서버가 API 모드다 */
- 409: {
+ /** @description 대표가 아니거나 G1 이후 */
+ 403: {
headers: {
[name: string]: unknown;
};
@@ -4410,8 +4720,25 @@ export interface paths {
/**
* @example {
* "error": {
- * "code": "PM_RELAY_DISABLED",
- * "message": "this server calls the model API directly (PM_PROVIDER=api); no pm-worker is needed"
+ * "code": "PROJECT_STARTED",
+ * "message": "project has started; members are frozen"
+ * }
+ * }
+ */
+ "application/json": unknown;
+ };
+ };
+ /** @description 배정되지 않은 에이전트 */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "error": {
+ * "code": "MEMBER_NOT_FOUND",
+ * "message": "agent is not a member of this project"
* }
* }
*/
@@ -4420,57 +4747,33 @@ export interface paths {
};
};
};
- put?: never;
- post?: never;
- delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
- "/pm/jobs/{jobId}/result": {
+ "/agents/me": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
- get?: never;
- put?: never;
/**
- * (중계 모드) PM 작업 결과 제출 — 대표의 pm-worker 전용
- * @description 모델이 낸 텍스트를 **그대로** 돌려준다. 해석·검증·교정·비용 기록은 API 모드와 똑같이 서버가 한다 —
- * 결과는 `GET /projects/{projectId}/pm/plans/{planId}`의 `draft`로 보인다.
- * 가져간 에이전트가 아니거나 이미 끝난(시간 제한·재시작) 작업이면 404 `PM_JOB_NOT_FOUND`.
+ * 에이전트 자기 설정
+ * @description Executor가 기동할 때 자기 프로젝트·역할·동시 실행 상한을 읽는다. 이 값들을 Executor 쪽 설정
+ * 파일에 적어두면 서버에서 바꿔도 반영되지 않으므로, 서버가 정본을 준다.
*/
- post: {
+ get: {
parameters: {
query?: never;
header?: never;
- path: {
- jobId: string;
- };
+ path?: never;
cookie?: never;
};
- requestBody: {
- content: {
- "application/json": {
- /** @description refusal·max_tokens면 서버가 refused·truncated로 닫는다 */
- stopReason: string | null;
- servedModel: string | null;
- /** @description 모델 출력(JSON 텍스트) 그대로 */
- text: string;
- usage: {
- inputTokens: number;
- outputTokens: number;
- cacheWriteTokens: number;
- cacheReadTokens: number;
- };
- };
- };
- };
+ requestBody?: never;
responses: {
- /** @description 받음 */
+ /** @description OK */
200: {
headers: {
[name: string]: unknown;
@@ -4479,70 +4782,49 @@ export interface paths {
/**
* @example {
* "data": {
- * "accepted": true
- * }
- * }
- */
- "application/json": components["schemas"]["PmRelayAccepted"];
- };
- };
- /** @description 없는 작업 */
- 404: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- /**
- * @example {
- * "error": {
- * "code": "PM_JOB_NOT_FOUND",
- * "message": "relay job not found (finished, timed out, or the server restarted)"
+ * "agentId": "2012e1df-0beb-43fd-ac09-d893ba56b01e",
+ * "projectId": "{{PROJECT_ID}}",
+ * "teamRole": "BACKEND",
+ * "maxConcurrent": 2
* }
* }
*/
- "application/json": unknown;
+ "application/json": {
+ data: components["schemas"]["AgentSelf"];
+ };
};
};
};
};
+ put?: never;
+ post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
- "/pm/jobs/{jobId}/failure": {
+ "/projects/{projectId}/pm/plans": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
- get?: never;
- put?: never;
- /**
- * (중계 모드) PM 작업 실패 보고 — 대표의 pm-worker 전용
- * @description 노트북에서 claude 실행이 실패했다(로그인 안 됨·실행 오류 등). 시간 제한까지 기다리지 않고 계획을 `failed(api_error)`로 닫는다.
- * `message`는 `error.detail.message`에 남는다.
- */
- post: {
+ /** PM 계획 요청 이력 (대표 전용) */
+ get: {
parameters: {
query?: never;
header?: never;
path: {
- jobId: string;
+ /** @example {{PROJECT_ID}} */
+ projectId: components["parameters"]["ProjectId"];
};
cookie?: never;
};
- requestBody: {
- content: {
- "application/json": {
- message: string;
- };
- };
- };
+ requestBody?: never;
responses: {
- /** @description 받음 */
+ /** @description 최근 요청 순 */
200: {
headers: {
[name: string]: unknown;
@@ -4551,52 +4833,30 @@ export interface paths {
/**
* @example {
* "data": {
- * "accepted": true
+ * "plans": []
* }
* }
*/
- "application/json": components["schemas"]["PmRelayAccepted"];
- };
- };
- /** @description 없는 작업 */
- 404: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- /**
- * @example {
- * "error": {
- * "code": "PM_JOB_NOT_FOUND",
- * "message": "relay job not found (finished, timed out, or the server restarted)"
- * }
- * }
- */
- "application/json": unknown;
+ "application/json": {
+ data: {
+ plans: components["schemas"]["PmPlan"][];
+ };
+ };
};
};
};
};
- delete?: never;
- options?: never;
- head?: never;
- patch?: never;
- trace?: never;
- };
- "/projects/{projectId}/pm/plans/{planId}/reject": {
- parameters: {
- query?: never;
- header?: never;
- path?: never;
- cookie?: never;
- };
- get?: never;
put?: never;
/**
- * 초안 반려 (대표 전용)
- * @description `ready` 초안을 버린다(`rejected`). **다시 받지 않고 닫는 것**이다 — 고쳐서 다시 받으려면 `revise`(수정 요청)를 쓴다.
- * 반려한 초안은 적용·수정 요청할 수 없다(409). 본문은 없어도 된다(사유는 선택, 1000자 이하).
- * `ready`가 아니면(작성 중·실패·이미 적용·이미 반려) 409 `PLAN_NOT_APPLICABLE`.
+ * 계획 초안 요청 (대표 전용)
+ * @description 대표의 지시로 PM(NOMOS 내장)이 명세(API·화면 계약 + 수용 기준)와 태스크 DAG(어느 레포·어느 역할이 무엇을 어떤 순서로) 초안을 만든다.
+ * **202로 바로 답하고 PM은 뒤에서 돈다**(수십 초~수 분). `GET .../pm/plans/{planId}`를 몇 초 간격으로 폴링해 `ready`·`failed`를 본다.
+ *
+ * - 프로젝트당 진행 중인 요청은 하나(409 `PM_PLAN_IN_PROGRESS`)
+ * - 호출 전에 "누적 PM 비용 + 이번 호출의 최대치 > PM 예산"이면 409 `PM_BUDGET_EXCEEDED` —
+ * 설계상 예산 초과는 대표 승인 카드가 떠야 하지만 **승인 경로 미구현**이라 거절만 한다
+ * - 서버에 PM 키가 없으면 503 `PM_UNAVAILABLE`
+ * - 초안은 명세·태스크 생성과 같은 코드 검증을 거친다. 틀리면 PM이 **한 번만** 고치고, 그래도 틀리면 `failed(invalid)`
*/
post: {
parameters: {
@@ -4605,25 +4865,24 @@ export interface paths {
path: {
/** @example {{PROJECT_ID}} */
projectId: components["parameters"]["ProjectId"];
- planId: string;
};
cookie?: never;
};
- requestBody?: {
+ requestBody: {
content: {
/**
* @example {
- * "reason": "출석은 이번 범위가 아니다"
+ * "instruction": "3주 안에 사내 스터디 관리 웹앱. 회원가입, 스터디 개설, 참여신청, 출석체크. 모바일 대응 필수."
* }
*/
"application/json": {
- reason?: string;
+ instruction: string;
};
};
};
responses: {
- /** @description 반려됨(status=rejected) */
- 200: {
+ /** @description 접수됨(status=pending) */
+ 202: {
headers: {
[name: string]: unknown;
};
@@ -4633,18 +4892,18 @@ export interface paths {
* "data": {
* "id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d",
* "projectId": "{{PROJECT_ID}}",
- * "status": "rejected",
+ * "status": "pending",
* "instruction": "3주 안에 …",
* "feedback": null,
* "parentPlanId": null,
- * "mode": "SEQUENTIAL",
+ * "mode": null,
* "draft": null,
* "error": null,
- * "costUsd": 0.115,
+ * "costUsd": 0,
* "createdAt": "2026-09-30T02:00:00.000Z",
* "appliedAt": null,
- * "rejectedAt": "2026-09-30T02:10:00.000Z",
- * "rejectReason": "출석은 이번 범위가 아니다",
+ * "rejectedAt": null,
+ * "rejectReason": null,
* "assignments": []
* }
* }
@@ -4654,8 +4913,17 @@ export interface paths {
};
};
};
- /** @description ready가 아니다 */
+ /** @description 진행 중인 요청 있음 · 예산 초과 · 끝난 프로젝트 */
409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ /** @description 서버에 PM 키(ANTHROPIC_API_KEY)가 없다 */
+ 503: {
headers: {
[name: string]: unknown;
};
@@ -4663,8 +4931,8 @@ export interface paths {
/**
* @example {
* "error": {
- * "code": "PLAN_NOT_APPLICABLE",
- * "message": "only a ready plan can be rejected (this one is applied)"
+ * "code": "PM_UNAVAILABLE",
+ * "message": "the built-in PM is not configured on this server (ANTHROPIC_API_KEY)"
* }
* }
*/
@@ -4679,7 +4947,7 @@ export interface paths {
patch?: never;
trace?: never;
};
- "/projects/{projectId}/specs": {
+ "/projects/{projectId}/pm/status": {
parameters: {
query?: never;
header?: never;
@@ -4687,10 +4955,13 @@ export interface paths {
cookie?: never;
};
/**
- * 명세 목록 (시험지 포함)
- * @description 태스크를 만들 때 명세를 고르는 목록이다. 볼 수 있는 범위는 태스크 목록과 같다 — 사람은 볼 수 있는 프로젝트
- * (대표는 전체, 팀원은 배정된 것), 에이전트는 자기 프로젝트. **에이전트 토큰으로 보면 잠긴 시험지만 있다**
- * (브리핑과 같은 규칙 — 초안을 보면 "통과하도록 코드를 맞추는" 대상이 된다).
+ * PM 준비 상태 (대표 전용)
+ * @description "계획 받기" 버튼을 켜기 전에 본다. **요청 자체를 막지는 않는다.**
+ * - `ready: false`, `reason: NO_API_KEY` — API 모드인데 서버에 키가 없다. 요청하면 503.
+ * - `ready: false`, `reason: WORKER_OFFLINE` — 중계 모드인데 대표 노트북의 pm-worker가 30초 넘게 작업을 확인하지 않았다(꺼져 있다).
+ * 요청하면 받아지지만 아무도 가져가지 않아 `PM_TIMEOUT_MS`(기본 10분) 뒤 `failed(timeout)`이 된다.
+ * pm-worker는 3초마다 확인하므로 켜면 몇 초 안에 `ready: true`가 된다.
+ * - `pendingPlanId`가 있으면 작성 중인 계획이 있어 새 요청은 409다.
*/
get: {
parameters: {
@@ -4704,7 +4975,7 @@ export interface paths {
};
requestBody?: never;
responses: {
- /** @description featureKey 순 */
+ /** @description 상태 */
200: {
headers: {
[name: string]: unknown;
@@ -4713,107 +4984,73 @@ export interface paths {
/**
* @example {
* "data": {
- * "specs": [
- * {
- * "id": "5d1f0c3e-7a2b-4c1d-9e8f-0a1b2c3d4e5f",
- * "featureKey": "F-05",
- * "title": "스터디 대기열 신청",
- * "content": "WHEN 정원이 찬 스터디에 신청하면 THEN 202와 waitlistPosition을 반환한다",
- * "version": 1,
- * "createdAt": "2026-09-30T02:00:00.000Z",
- * "tests": [
- * {
- * "id": "6e2a1d4f-8b3c-4d2e-af90-1b2c3d4e5f60",
- * "criterion": "정원이 찬 신청은 202와 대기 순번",
- * "testCode": "expect(res.status).toBe(202)",
- * "lockedAt": "2026-09-30T02:00:00.000Z"
- * }
- * ]
- * }
- * ]
+ * "provider": "relay",
+ * "ready": false,
+ * "reason": "WORKER_OFFLINE",
+ * "workerLastSeenAt": null,
+ * "budgetUsd": 5,
+ * "spentUsd": 0.1077,
+ * "pendingPlanId": null
* }
* }
*/
"application/json": {
data: {
- specs: components["schemas"]["Spec"][];
+ /** @enum {string} */
+ provider: "api" | "relay";
+ ready: boolean;
+ /** @enum {string|null} */
+ reason: "NO_API_KEY" | "WORKER_OFFLINE" | null;
+ /**
+ * Format: date-time
+ * @description 중계 모드에서 pm-worker가 마지막으로 작업을 확인한 시각
+ */
+ workerLastSeenAt: string | null;
+ budgetUsd: number;
+ spentUsd: number;
+ /** Format: uuid */
+ pendingPlanId: string | null;
};
};
};
};
- /** @description 볼 수 없는 프로젝트 */
- 403: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- /**
- * @example {
- * "error": {
- * "code": "NOT_PROJECT_MEMBER",
- * "message": "you are not a member of this project"
- * }
- * }
- */
- "application/json": unknown;
- };
- };
};
};
put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/projects/{projectId}/pm/plans/{planId}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
/**
- * 명세 생성 (대표 전용)
- * @description 명세 1개와 시험지(수용 기준) 여러 개를 만든다. 프로젝트가 `planning`·`active`일 때만.
- *
- * **시험지의 `locked`는 필수이고, 만들 때만 정한다.** 잠근 시험지만 V2의 근거가 된다
- * (`locked_at < artifacts.created_at`). 나중에 잠그는 API는 없다 — 이미 제출된 산출물보다 늦게 잠긴 시험지가 생기기 때문이다.
- *
- * **시험지는 V2에서 팀원 노트북에서 실행되는 코드다.**
- *
- * 검증 위반은 전부 모아 **422 `PLAN_INVALID`**의 `details`로 돌려준다(같은 featureKey 등).
+ * 계획 상태·초안 (대표 전용)
+ * @description `pending`이면 작성 중이다. `ready`면 `draft`를 검토한다 — 명세의 계약과 태스크별 담당 역할·선행 관계.
+ * `failed`면 `error.reason`으로 이유를 보여준다.
*/
- post: {
+ get: {
parameters: {
query?: never;
header?: never;
path: {
/** @example {{PROJECT_ID}} */
projectId: components["parameters"]["ProjectId"];
+ planId: string;
};
cookie?: never;
};
- requestBody: {
- content: {
- /**
- * @example {
- * "featureKey": "F-05",
- * "title": "스터디 대기열 신청",
- * "content": "WHEN 정원이 찬 스터디에 신청하면 THEN 202와 waitlistPosition을 반환한다",
- * "tests": [
- * {
- * "criterion": "정원이 찬 신청은 202와 대기 순번",
- * "testCode": "expect(res.status).toBe(202)",
- * "locked": true
- * }
- * ]
- * }
- */
- "application/json": {
- featureKey: string;
- title: string;
- content: string;
- tests?: {
- criterion: string;
- testCode: string;
- /** @description 필수. true면 지금 잠긴다(V2 근거) */
- locked: boolean;
- }[];
- };
- };
- };
+ requestBody?: never;
responses: {
- /** @description 만들어진 명세 */
- 201: {
+ /** @description 계획 */
+ 200: {
headers: {
[name: string]: unknown;
};
@@ -4821,23 +5058,40 @@ export interface paths {
/**
* @example {
* "data": {
- * "id": "5d1f0c3e-7a2b-4c1d-9e8f-0a1b2c3d4e5f",
- * "featureKey": "F-05",
- * "title": "스터디 대기열 신청",
- * "content": "WHEN … THEN …",
- * "version": 1,
+ * "id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d",
+ * "projectId": "{{PROJECT_ID}}",
+ * "status": "ready",
+ * "instruction": "3주 안에 …",
+ * "feedback": null,
+ * "parentPlanId": null,
+ * "mode": "SEQUENTIAL",
+ * "draft": {
+ * "mode": "SEQUENTIAL",
+ * "rationale": "요구사항이 작고 명확하다",
+ * "estimate": {
+ * "workingDays": 10,
+ * "notes": ""
+ * },
+ * "specs": [],
+ * "tasks": []
+ * },
+ * "error": null,
+ * "costUsd": 0.4213,
* "createdAt": "2026-09-30T02:00:00.000Z",
- * "tests": []
+ * "appliedAt": null,
+ * "rejectedAt": null,
+ * "rejectReason": null,
+ * "assignments": []
* }
* }
*/
"application/json": {
- data: components["schemas"]["Spec"];
+ data: components["schemas"]["PmPlan"];
};
};
};
- /** @description 대표가 아니거나 다른 조직 */
- 403: {
+ /** @description 없는 계획 */
+ 404: {
headers: {
[name: string]: unknown;
};
@@ -4845,25 +5099,941 @@ export interface paths {
/**
* @example {
* "error": {
- * "code": "NOT_REPRESENTATIVE",
- * "message": "this action requires the organization representative"
+ * "code": "PLAN_NOT_FOUND",
+ * "message": "plan not found"
* }
* }
*/
"application/json": unknown;
};
};
- /** @description 끝났거나 멈춘 프로젝트(completed·aborted·halted) */
- 409: {
- headers: {
- [name: string]: unknown;
- };
- content: {
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/projects/{projectId}/pm/plans/{planId}/revise": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 수정 요청 (대표 전용)
+ * @description `ready`인 초안에 피드백을 붙여 새 초안을 요청한다(이전 초안 + 피드백으로 새로 쓴다). 새 계획이 pending으로 생기고
+ * 이전 초안은 그대로 남는다. 한 수정 체인에서는 하나만 적용할 수 있다.
+ *
+ * **수정 요청은 한 체인에서 3회까지**(서버 설정 `PM_MAX_REVISIONS`, 기본 3). 원본 요청은 세지 않고, 실패한 수정 요청도 센다.
+ * 넘으면 409 `PLAN_REVISION_LIMIT`, `details: { limit, used }`. 새 계획 요청(`POST …/pm/plans`)은 새 체인이라 0부터 다시 센다.
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @example {{PROJECT_ID}} */
+ projectId: components["parameters"]["ProjectId"];
+ planId: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ /**
+ * @example {
+ * "feedback": "출석체크는 다음 단계로 미루고, 스터디 개설에 정원 설정을 넣어줘"
+ * }
+ */
+ "application/json": {
+ feedback: string;
+ };
+ };
+ };
+ responses: {
+ /** @description 새 초안 접수됨(status=pending) */
+ 202: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "data": {
+ * "id": "2c3d4e5f-6071-4b2c-9d3e-4f5a6b7c8d9e",
+ * "projectId": "{{PROJECT_ID}}",
+ * "status": "pending",
+ * "instruction": "3주 안에 …",
+ * "feedback": "출석체크는 다음 단계로",
+ * "parentPlanId": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d",
+ * "mode": null,
+ * "draft": null,
+ * "error": null,
+ * "costUsd": 0,
+ * "createdAt": "2026-09-30T02:05:00.000Z",
+ * "appliedAt": null,
+ * "rejectedAt": null,
+ * "rejectReason": null,
+ * "assignments": []
+ * }
+ * }
+ */
+ "application/json": {
+ data: components["schemas"]["PmPlan"];
+ };
+ };
+ };
+ /** @description ready가 아닌 초안, 또는 수정 요청 한도 초과 */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/projects/{projectId}/pm/plans/{planId}/apply": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 초안 적용 (대표 전용)
+ * @description 저장된 초안을 **그대로** 명세·태스크로 만든다(명세·태스크 생성과 같은 검증·잠금, `source: pm`). 시험지는 만들지 않는다.
+ * 태스크는 READY라 배정된 에이전트가 바로 수령할 수 있다. **G1(프로젝트 시작)이 아니다** — 프로젝트 상태는 그대로다.
+ *
+ * - 두 번 적용하거나 같은 수정 체인의 다른 초안을 적용하면 409 `PLAN_NOT_APPLICABLE`
+ * - 초안 뒤에 상황이 바뀌어 검증에 걸리면 422 `PLAN_INVALID` — 아무것도 만들지 않고 계획은 `ready`로 남는다. 수정 요청으로 이어가면 된다
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @example {{PROJECT_ID}} */
+ projectId: components["parameters"]["ProjectId"];
+ planId: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description 적용됨(status=applied) */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "data": {
+ * "id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d",
+ * "projectId": "{{PROJECT_ID}}",
+ * "status": "applied",
+ * "instruction": "3주 안에 …",
+ * "feedback": null,
+ * "parentPlanId": null,
+ * "mode": "SEQUENTIAL",
+ * "draft": null,
+ * "error": null,
+ * "costUsd": 0.4213,
+ * "createdAt": "2026-09-30T02:00:00.000Z",
+ * "appliedAt": "2026-09-30T02:10:00.000Z",
+ * "rejectedAt": null,
+ * "rejectReason": null,
+ * "assignments": []
+ * }
+ * }
+ */
+ "application/json": {
+ data: components["schemas"]["PmPlan"];
+ };
+ };
+ };
+ /** @description 적용할 수 없는 상태 */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "error": {
+ * "code": "PLAN_NOT_APPLICABLE",
+ * "message": "another plan in this revision chain has already been applied"
+ * }
+ * }
+ */
+ "application/json": unknown;
+ };
+ };
+ /** @description 초안 이후 상황이 바뀌어 검증 실패(계획은 ready로 남는다) */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "error": {
+ * "code": "PLAN_INVALID",
+ * "message": "명세·태스크 검증 실패 1건",
+ * "details": [
+ * {
+ * "where": "tasks[api]",
+ * "message": "태스크 \"T-1\"이 프로젝트에 이미 있다 (다시 보낸 건 아닌가?)"
+ * }
+ * ]
+ * }
+ * }
+ */
+ "application/json": unknown;
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/pm/jobs/next": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * (중계 모드) 다음 PM 작업 가져가기 — 대표의 pm-worker 전용
+ * @description **서버가 `PM_PROVIDER=relay`일 때만 쓴다.** 결제 전까지 PM의 모델 호출을 대표 노트북의 Claude Code로 대신 돌리는 임시 방식이다 —
+ * `npm run executor pm-worker`가 몇 초마다 부른다. 사람이 직접 부를 일은 없다.
+ * API 모드(`PM_PROVIDER=api`, 기본)에서는 409 `PM_RELAY_DISABLED`.
+ *
+ * 조직 **대표 본인의** 에이전트만 가져간다(그 밖은 403 `NOT_REPRESENTATIVE`). 한 번 가져간 작업은 다시 나오지 않고, 없으면 `job: null`.
+ * 아무도 가져가지 않으면 `PM_TIMEOUT_MS` 뒤 계획이 `failed(timeout)`가 된다.
+ */
+ get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description 작업(없으면 null) */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "data": {
+ * "job": null
+ * }
+ * }
+ */
+ "application/json": {
+ data: {
+ job: components["schemas"]["PmRelayJob"];
+ };
+ };
+ };
+ };
+ /** @description 서버가 API 모드다 */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "error": {
+ * "code": "PM_RELAY_DISABLED",
+ * "message": "this server calls the model API directly (PM_PROVIDER=api); no pm-worker is needed"
+ * }
+ * }
+ */
+ "application/json": unknown;
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/pm/jobs/{jobId}/result": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * (중계 모드) PM 작업 결과 제출 — 대표의 pm-worker 전용
+ * @description 모델이 낸 텍스트를 **그대로** 돌려준다. 해석·검증·교정·비용 기록은 API 모드와 똑같이 서버가 한다 —
+ * 결과는 `GET /projects/{projectId}/pm/plans/{planId}`의 `draft`로 보인다.
+ * 가져간 에이전트가 아니거나 이미 끝난(시간 제한·재시작) 작업이면 404 `PM_JOB_NOT_FOUND`.
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ jobId: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** @description refusal·max_tokens면 서버가 refused·truncated로 닫는다 */
+ stopReason: string | null;
+ servedModel: string | null;
+ /** @description 모델 출력(JSON 텍스트) 그대로 */
+ text: string;
+ usage: {
+ inputTokens: number;
+ outputTokens: number;
+ cacheWriteTokens: number;
+ cacheReadTokens: number;
+ };
+ };
+ };
+ };
+ responses: {
+ /** @description 받음 */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "data": {
+ * "accepted": true
+ * }
+ * }
+ */
+ "application/json": components["schemas"]["PmRelayAccepted"];
+ };
+ };
+ /** @description 없는 작업 */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "error": {
+ * "code": "PM_JOB_NOT_FOUND",
+ * "message": "relay job not found (finished, timed out, or the server restarted)"
+ * }
+ * }
+ */
+ "application/json": unknown;
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/pm/jobs/{jobId}/failure": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * (중계 모드) PM 작업 실패 보고 — 대표의 pm-worker 전용
+ * @description 노트북에서 claude 실행이 실패했다(로그인 안 됨·실행 오류 등). 시간 제한까지 기다리지 않고 계획을 `failed(api_error)`로 닫는다.
+ * `message`는 `error.detail.message`에 남는다.
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ jobId: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ message: string;
+ };
+ };
+ };
+ responses: {
+ /** @description 받음 */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "data": {
+ * "accepted": true
+ * }
+ * }
+ */
+ "application/json": components["schemas"]["PmRelayAccepted"];
+ };
+ };
+ /** @description 없는 작업 */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "error": {
+ * "code": "PM_JOB_NOT_FOUND",
+ * "message": "relay job not found (finished, timed out, or the server restarted)"
+ * }
+ * }
+ */
+ "application/json": unknown;
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/projects/{projectId}/pm/plans/{planId}/reject": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 초안 반려 (대표 전용)
+ * @description `ready` 초안을 버린다(`rejected`). **다시 받지 않고 닫는 것**이다 — 고쳐서 다시 받으려면 `revise`(수정 요청)를 쓴다.
+ * 반려한 초안은 적용·수정 요청할 수 없다(409). 본문은 없어도 된다(사유는 선택, 1000자 이하).
+ * `ready`가 아니면(작성 중·실패·이미 적용·이미 반려) 409 `PLAN_NOT_APPLICABLE`.
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @example {{PROJECT_ID}} */
+ projectId: components["parameters"]["ProjectId"];
+ planId: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: {
+ content: {
+ /**
+ * @example {
+ * "reason": "출석은 이번 범위가 아니다"
+ * }
+ */
+ "application/json": {
+ reason?: string;
+ };
+ };
+ };
+ responses: {
+ /** @description 반려됨(status=rejected) */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "data": {
+ * "id": "1b2c3d4e-5f60-4a1b-8c2d-3e4f5a6b7c8d",
+ * "projectId": "{{PROJECT_ID}}",
+ * "status": "rejected",
+ * "instruction": "3주 안에 …",
+ * "feedback": null,
+ * "parentPlanId": null,
+ * "mode": "SEQUENTIAL",
+ * "draft": null,
+ * "error": null,
+ * "costUsd": 0.115,
+ * "createdAt": "2026-09-30T02:00:00.000Z",
+ * "appliedAt": null,
+ * "rejectedAt": "2026-09-30T02:10:00.000Z",
+ * "rejectReason": "출석은 이번 범위가 아니다",
+ * "assignments": []
+ * }
+ * }
+ */
+ "application/json": {
+ data: components["schemas"]["PmPlan"];
+ };
+ };
+ };
+ /** @description ready가 아니다 */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "error": {
+ * "code": "PLAN_NOT_APPLICABLE",
+ * "message": "only a ready plan can be rejected (this one is applied)"
+ * }
+ * }
+ */
+ "application/json": unknown;
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/projects/{projectId}/specs": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 명세 목록 (시험지 포함)
+ * @description 태스크를 만들 때 명세를 고르는 목록이다. 볼 수 있는 범위는 태스크 목록과 같다 — 사람은 볼 수 있는 프로젝트
+ * (대표는 전체, 팀원은 배정된 것), 에이전트는 자기 프로젝트. **에이전트 토큰으로 보면 잠긴 시험지만 있다**
+ * (브리핑과 같은 규칙 — 초안을 보면 "통과하도록 코드를 맞추는" 대상이 된다).
+ */
+ get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @example {{PROJECT_ID}} */
+ projectId: components["parameters"]["ProjectId"];
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description featureKey 순 */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "data": {
+ * "specs": [
+ * {
+ * "id": "5d1f0c3e-7a2b-4c1d-9e8f-0a1b2c3d4e5f",
+ * "featureKey": "F-05",
+ * "title": "스터디 대기열 신청",
+ * "content": "WHEN 정원이 찬 스터디에 신청하면 THEN 202와 waitlistPosition을 반환한다",
+ * "version": 1,
+ * "createdAt": "2026-09-30T02:00:00.000Z",
+ * "tests": [
+ * {
+ * "id": "6e2a1d4f-8b3c-4d2e-af90-1b2c3d4e5f60",
+ * "criterion": "정원이 찬 신청은 202와 대기 순번",
+ * "testCode": "expect(res.status).toBe(202)",
+ * "lockedAt": "2026-09-30T02:00:00.000Z"
+ * }
+ * ]
+ * }
+ * ]
+ * }
+ * }
+ */
+ "application/json": {
+ data: {
+ specs: components["schemas"]["Spec"][];
+ };
+ };
+ };
+ };
+ /** @description 볼 수 없는 프로젝트 */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "error": {
+ * "code": "NOT_PROJECT_MEMBER",
+ * "message": "you are not a member of this project"
+ * }
+ * }
+ */
+ "application/json": unknown;
+ };
+ };
+ };
+ };
+ put?: never;
+ /**
+ * 명세 생성 (대표 전용)
+ * @description 명세 1개와 시험지(수용 기준) 여러 개를 만든다. 프로젝트가 `planning`·`active`일 때만.
+ *
+ * **시험지의 `locked`는 필수이고, 만들 때만 정한다.** 잠근 시험지만 V2의 근거가 된다
+ * (`locked_at < artifacts.created_at`). 나중에 잠그는 API는 없다 — 이미 제출된 산출물보다 늦게 잠긴 시험지가 생기기 때문이다.
+ *
+ * **시험지는 V2에서 팀원 노트북에서 실행되는 코드다.**
+ *
+ * 검증 위반은 전부 모아 **422 `PLAN_INVALID`**의 `details`로 돌려준다(같은 featureKey 등).
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @example {{PROJECT_ID}} */
+ projectId: components["parameters"]["ProjectId"];
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ /**
+ * @example {
+ * "featureKey": "F-05",
+ * "title": "스터디 대기열 신청",
+ * "content": "WHEN 정원이 찬 스터디에 신청하면 THEN 202와 waitlistPosition을 반환한다",
+ * "tests": [
+ * {
+ * "criterion": "정원이 찬 신청은 202와 대기 순번",
+ * "testCode": "expect(res.status).toBe(202)",
+ * "locked": true
+ * }
+ * ]
+ * }
+ */
+ "application/json": {
+ featureKey: string;
+ title: string;
+ content: string;
+ tests?: {
+ criterion: string;
+ testCode: string;
+ /** @description 필수. true면 지금 잠긴다(V2 근거) */
+ locked: boolean;
+ }[];
+ };
+ };
+ };
+ responses: {
+ /** @description 만들어진 명세 */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "data": {
+ * "id": "5d1f0c3e-7a2b-4c1d-9e8f-0a1b2c3d4e5f",
+ * "featureKey": "F-05",
+ * "title": "스터디 대기열 신청",
+ * "content": "WHEN … THEN …",
+ * "version": 1,
+ * "createdAt": "2026-09-30T02:00:00.000Z",
+ * "tests": []
+ * }
+ * }
+ */
+ "application/json": {
+ data: components["schemas"]["Spec"];
+ };
+ };
+ };
+ /** @description 대표가 아니거나 다른 조직 */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "error": {
+ * "code": "NOT_REPRESENTATIVE",
+ * "message": "this action requires the organization representative"
+ * }
+ * }
+ */
+ "application/json": unknown;
+ };
+ };
+ /** @description 끝났거나 멈춘 프로젝트(completed·aborted·halted) */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "error": {
+ * "code": "PROJECT_NOT_OPEN",
+ * "message": "project is completed; specs and tasks can be added only while planning or active"
+ * }
+ * }
+ */
+ "application/json": unknown;
+ };
+ };
+ /** @description 검증 위반 전부 */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "error": {
+ * "code": "PLAN_INVALID",
+ * "message": "명세·태스크 검증 실패 1건",
+ * "details": [
+ * {
+ * "where": "specs[F-05]",
+ * "message": "명세 F-05가 프로젝트에 이미 있다 (다시 보낸 건 아닌가?)"
+ * }
+ * ]
+ * }
+ * }
+ */
+ "application/json": unknown;
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/projects/{projectId}/tasks": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 태스크 목록
+ * @description 웹 UI(사람 토큰)와 Executor 폴링(에이전트 토큰)이 **같은 엔드포인트**를 쓴다.
+ *
+ * 에이전트 토큰이면 `teamRole` 질의를 무시하고 자기 역할로 강제 필터한다 — 남의 역할 태스크가
+ * 목록에 아예 없어야 "잡아보고 403"을 반복하지 않는다.
+ */
+ get: {
+ parameters: {
+ query?: {
+ state?: "READY" | "CLAIMED" | "IN_PROGRESS" | "VERIFYING" | "AWAITING_APPROVAL" | "BLOCKED" | "ESCALATED" | "DONE";
+ /** @description 에이전트 토큰이면 무시된다 */
+ teamRole?: "FRONTEND" | "BACKEND";
+ limit?: number;
+ };
+ header?: never;
+ path: {
+ /** @example {{PROJECT_ID}} */
+ projectId: components["parameters"]["ProjectId"];
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description 생성 순서대로. 정렬이 결정적이어야 폴링이 같은 태스크를 두고 경합하지 않는다. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "data": {
+ * "tasks": [
+ * {
+ * "id": "{{TASK_BE}}",
+ * "title": "T-001 참여 신청 API 구현",
+ * "state": "READY",
+ * "teamRole": "BACKEND",
+ * "retryCount": 0
+ * }
+ * ]
+ * }
+ * }
+ */
+ "application/json": {
+ data: {
+ tasks: components["schemas"]["Task"][];
+ };
+ };
+ };
+ };
+ };
+ };
+ put?: never;
+ /**
+ * 태스크 생성 (대표 전용)
+ * @description 만들어진 태스크는 `READY`라 배정된 에이전트가 바로 수령할 수 있다. 프로젝트가 `planning`·`active`일 때만.
+ *
+ * - `repoId` — 이 프로젝트에 연결된 레포만
+ * - `specId` — **IMPLEMENT는 필수**, INTEGRATION·REWORK는 생략 가능
+ * - `teamRole` — 필수. `null`은 역할 제한 없음(통합 태스크)
+ * - `dependsOn` — 이 프로젝트의 기존 태스크 id. 선행이 DONE이 돼야 수령할 수 있다
+ * - 같은 제목은 거부한다(버튼 두 번 누르기 방지)
+ *
+ * 검증 위반은 전부 모아 **422 `PLAN_INVALID`**의 `details`로 돌려준다.
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @example {{PROJECT_ID}} */
+ projectId: components["parameters"]["ProjectId"];
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ /**
+ * @example {
+ * "title": "T-052 대기열 신청 버튼",
+ * "teamRole": "FRONTEND",
+ * "repoId": "{{REPO_API}}",
+ * "specId": null,
+ * "kind": "INTEGRATION",
+ * "dependsOn": []
+ * }
+ */
+ "application/json": {
+ title: string;
+ /** @enum {string|null} */
+ teamRole: "FRONTEND" | "BACKEND" | null;
+ /**
+ * @default IMPLEMENT
+ * @enum {string}
+ */
+ kind?: "IMPLEMENT" | "INTEGRATION" | "REWORK";
+ /** Format: uuid */
+ repoId: string;
+ /** Format: uuid */
+ specId?: string | null;
+ dependsOn?: string[];
+ };
+ };
+ };
+ responses: {
+ /** @description 만들어진 태스크(READY) */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "data": {
+ * "id": "7f3b2e5a-9c4d-4e3f-b0a1-2c3d4e5f6071",
+ * "projectId": "{{PROJECT_ID}}",
+ * "repoId": "{{REPO_API}}",
+ * "specId": null,
+ * "kind": "INTEGRATION",
+ * "title": "T-052 대기열 신청 버튼",
+ * "state": "READY",
+ * "teamRole": "FRONTEND",
+ * "assigneeAgentId": null,
+ * "branchName": null,
+ * "blockedReason": null,
+ * "retryCount": 0,
+ * "claimedAt": null
+ * }
+ * }
+ */
+ "application/json": {
+ data: components["schemas"]["Task"];
+ };
+ };
+ };
+ /** @description 대표가 아니거나 다른 조직 */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "error": {
+ * "code": "NOT_REPRESENTATIVE",
+ * "message": "this action requires the organization representative"
+ * }
+ * }
+ */
+ "application/json": unknown;
+ };
+ };
+ /** @description 끝났거나 멈춘 프로젝트 */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
/**
* @example {
* "error": {
* "code": "PROJECT_NOT_OPEN",
- * "message": "project is completed; specs and tasks can be added only while planning or active"
+ * "message": "project is halted; specs and tasks can be added only while planning or active"
* }
* }
*/
@@ -4880,11 +6050,15 @@ export interface paths {
* @example {
* "error": {
* "code": "PLAN_INVALID",
- * "message": "명세·태스크 검증 실패 1건",
+ * "message": "명세·태스크 검증 실패 2건",
* "details": [
* {
- * "where": "specs[F-05]",
- * "message": "명세 F-05가 프로젝트에 이미 있다 (다시 보낸 건 아닌가?)"
+ * "where": "tasks[task]",
+ * "message": "레포 … 는 이 프로젝트에 연결돼 있지 않다 (연결된 레포: acme/study-api)"
+ * },
+ * {
+ * "where": "tasks[task]",
+ * "message": "구현(IMPLEMENT) 태스크에는 명세가 필요하다 (명세 없이 만들 수 있는 건 INTEGRATION·REWORK)"
* }
* ]
* }
@@ -4901,7 +6075,7 @@ export interface paths {
patch?: never;
trace?: never;
};
- "/projects/{projectId}/tasks": {
+ "/tasks/{taskId}/briefing": {
parameters: {
query?: never;
header?: never;
@@ -4909,30 +6083,27 @@ export interface paths {
cookie?: never;
};
/**
- * 태스크 목록
- * @description 웹 UI(사람 토큰)와 Executor 폴링(에이전트 토큰)이 **같은 엔드포인트**를 쓴다.
+ * 태스크 브리핑 — 프롬프트·settings.json 재료 한 번에
+ * @description Executor는 팀원 노트북에서 돌아 DB를 직접 못 본다. 여러 엔드포인트를 조합하게 하면 조합 방식이
+ * Executor마다 갈리므로 서버가 한 번에 준다 — 명세, 서버가 렌더한 인계 노트 블록(`notesBlock`),
+ * 쓸 수 있는 경로, `.claude/settings.json`에 그대로 깔 `claudeSettings`, 그리고 V2가 돌릴 `specTests`.
*
- * 에이전트 토큰이면 `teamRole` 질의를 무시하고 자기 역할로 강제 필터한다 — 남의 역할 태스크가
- * 목록에 아예 없어야 "잡아보고 403"을 반복하지 않는다.
+ * **CLAIM 전에도 받을 수 있다.** Executor가 작업공간을 먼저 만들고, claim은 모델이 도구로 하기 때문이다.
+ * 남의 역할 태스크는 403이다 — 명세·노트를 미리 보지 못하게 한다.
*/
get: {
parameters: {
- query?: {
- state?: "READY" | "CLAIMED" | "IN_PROGRESS" | "VERIFYING" | "AWAITING_APPROVAL" | "BLOCKED" | "ESCALATED" | "DONE";
- /** @description 에이전트 토큰이면 무시된다 */
- teamRole?: "FRONTEND" | "BACKEND";
- limit?: number;
- };
+ query?: never;
header?: never;
path: {
- /** @example {{PROJECT_ID}} */
- projectId: components["parameters"]["ProjectId"];
+ /** @example {{TASK_BE}} */
+ taskId: components["parameters"]["TaskId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
- /** @description 생성 순서대로. 정렬이 결정적이어야 폴링이 같은 태스크를 두고 경합하지 않는다. */
+ /** @description OK */
200: {
headers: {
[name: string]: unknown;
@@ -4941,47 +6112,158 @@ export interface paths {
/**
* @example {
* "data": {
- * "tasks": [
+ * "task": {
+ * "id": "{{TASK_BE}}",
+ * "title": "T-001 참여 신청 API 구현",
+ * "state": "READY"
+ * },
+ * "repo": {
+ * "id": "{{REPO_API}}",
+ * "fullName": "acme/study-api",
+ * "defaultBranch": "main",
+ * "devBranch": "dev",
+ * "cloneUrl": null
+ * },
+ * "spec": {
+ * "featureKey": "F-03",
+ * "title": "참여 신청 API",
+ * "content": "WHEN 정원이 찬 스터디에 신청하면 THEN 409를 반환한다"
+ * },
+ * "notesBlock": "[인계 노트]\n#1 [명세 이탈] quota check moved to service\n · handler was too late\n 영향: contracts/F-03.yaml",
+ * "writablePaths": [
* {
- * "id": "{{TASK_BE}}",
- * "title": "T-001 참여 신청 API 구현",
- * "state": "READY",
- * "teamRole": "BACKEND",
- * "retryCount": 0
+ * "pathPattern": "src/**",
+ * "access": "write",
+ * "ownerRole": "BACKEND"
* }
- * ]
+ * ],
+ * "claudeSettings": {
+ * "permissions": {
+ * "deny": [
+ * "Read(**\/.env*)",
+ * "Read(!**\/.env.example)",
+ * "Edit(/contracts/**)",
+ * "Edit(**\/secrets/**)"
+ * ]
+ * }
+ * },
+ * "specTests": [
+ * {
+ * "id": "5f0e...",
+ * "criterion": "정원이 찬 스터디 신청은 409다",
+ * "lockedAt": "2026-09-21T00:00:00.000Z"
+ * }
+ * ],
+ * "policyHash": "{{POLICY_HASH}}"
* }
* }
*/
+ "application/json": {
+ data: components["schemas"]["TaskBriefing"];
+ };
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/tasks/{taskId}/runs/start": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Claude 실행 시작 보고 (Executor)
+ * @description Executor가 Claude를 띄우기 직전에 부른다. 룸에 "에이전트를 실행합니다"가 남는다(이벤트 `AGENT_RUN_STARTED`).
+ * 태스크를 잡는 것(`claim_task`)은 실행 **안에서** 모델이 하므로, 이 에이전트가 그 태스크를 잡고 있거나(CLAIMED·IN_PROGRESS)
+ * **지금 잡을 수 있으면**(READY·담당 없음·내 역할 또는 역할 제한 없음) 받는다. 아니면 409 `RUN_NOT_ALLOWED`.
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @example {{TASK_BE}} */
+ taskId: components["parameters"]["TaskId"];
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description 시작 기록. attempt는 그때의 재시도 횟수 */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
"application/json": {
data: {
- tasks: components["schemas"]["Task"][];
+ /** Format: uuid */
+ taskId: string;
+ attempt: number;
};
};
};
};
+ /** @description 이 에이전트가 잡고 있지도, 잡을 수도 없는 태스크 */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "error": {
+ * "code": "RUN_NOT_ALLOWED",
+ * "message": "task ... is not held by this agent (state READY)"
+ * }
+ * }
+ */
+ "application/json": unknown;
+ };
+ };
};
};
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/tasks/{taskId}/runs/end": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
put?: never;
/**
- * 태스크 생성 (대표 전용)
- * @description 만들어진 태스크는 `READY`라 배정된 에이전트가 바로 수령할 수 있다. 프로젝트가 `planning`·`active`일 때만.
- *
- * - `repoId` — 이 프로젝트에 연결된 레포만
- * - `specId` — **IMPLEMENT는 필수**, INTEGRATION·REWORK는 생략 가능
- * - `teamRole` — 필수. `null`은 역할 제한 없음(통합 태스크)
- * - `dependsOn` — 이 프로젝트의 기존 태스크 id. 선행이 DONE이 돼야 수령할 수 있다
- * - 같은 제목은 거부한다(버튼 두 번 누르기 방지)
- *
- * 검증 위반은 전부 모아 **422 `PLAN_INVALID`**의 `details`로 돌려준다.
+ * Claude 실행 종료 보고 (Executor)
+ * @description Claude가 끝나면 부른다(이벤트 `AGENT_RUN_ENDED`). **제출 여부는 서버가 태스크 상태로 정한다** — 이 에이전트가 아직 잡고 있으면
+ * 제출하지 않은 것이다. 그때는 같은 트랜잭션에서 태스크가 **BLOCKED(AGENT_STOPPED)**로 바뀌고(이벤트 `TASK_BLOCKED`), 룸에
+ * "멈췄습니다 — 사유 / 거부된 명령 → 대표 확인 후 재개가 필요합니다"가 남는다. 재시도 횟수는 오르지 않는다.
+ * Executor가 꺼져 이 보고조차 오지 않으면 서버 감시가 45분 동안 응답이 없는 수령을 같은 방식으로 멈춘다.
+ * 열린 실행(시작하고 아직 안 끝낸 것)이 없으면 409 `RUN_NOT_OPEN`.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
- /** @example {{PROJECT_ID}} */
- projectId: components["parameters"]["ProjectId"];
+ /** @example {{TASK_BE}} */
+ taskId: components["parameters"]["TaskId"];
};
cookie?: never;
};
@@ -4989,79 +6271,144 @@ export interface paths {
content: {
/**
* @example {
- * "title": "T-052 대기열 신청 버튼",
- * "teamRole": "FRONTEND",
- * "repoId": "{{REPO_API}}",
- * "specId": null,
- * "kind": "INTEGRATION",
- * "dependsOn": []
+ * "outcome": "completed",
+ * "committed": false,
+ * "durationMs": 48000,
+ * "exitCode": 0,
+ * "lastMessage": "git merge 권한이 없어 통합 확인을 할 수 없습니다",
+ * "deniedCommands": [
+ * "git fetch --all"
+ * ]
* }
*/
"application/json": {
- title: string;
- /** @enum {string|null} */
- teamRole: "FRONTEND" | "BACKEND" | null;
/**
- * @default IMPLEMENT
+ * @description completed=정상 종료, timeout=시간 제한, failed=비정상 종료
* @enum {string}
*/
- kind?: "IMPLEMENT" | "INTEGRATION" | "REWORK";
- /** Format: uuid */
- repoId: string;
- /** Format: uuid */
- specId?: string | null;
- dependsOn?: string[];
+ outcome: "completed" | "timeout" | "failed";
+ /** @description 실행 동안 새 커밋이 생겼나 */
+ committed: boolean;
+ durationMs: number;
+ exitCode: number | null;
+ /** @description 모델의 마지막 말. 제출하지 않았으면 멈춤 사유로 룸에 보인다 */
+ lastMessage?: string | null;
+ /** @description 실행 중 거부된 쉘 명령(Executor가 실행 기록에서 뽑는다) */
+ deniedCommands?: string[];
};
};
};
responses: {
- /** @description 만들어진 태스크(READY) */
- 201: {
+ /** @description 종료 기록 */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ data: {
+ submitted: boolean;
+ taskState: string;
+ /** @description 제출하지 않아 BLOCKED(AGENT_STOPPED)로 멈췄다 */
+ blocked: boolean;
+ };
+ };
+ };
+ };
+ /** @description 열린 실행이 없음 */
+ 409: {
headers: {
[name: string]: unknown;
};
content: {
/**
* @example {
- * "data": {
- * "id": "7f3b2e5a-9c4d-4e3f-b0a1-2c3d4e5f6071",
- * "projectId": "{{PROJECT_ID}}",
- * "repoId": "{{REPO_API}}",
- * "specId": null,
- * "kind": "INTEGRATION",
- * "title": "T-052 대기열 신청 버튼",
- * "state": "READY",
- * "teamRole": "FRONTEND",
- * "assigneeAgentId": null,
- * "branchName": null,
- * "blockedReason": null,
- * "retryCount": 0
+ * "error": {
+ * "code": "RUN_NOT_OPEN",
+ * "message": "no open run for task ..."
* }
* }
*/
- "application/json": {
- data: components["schemas"]["Task"];
- };
+ "application/json": unknown;
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/tasks/{taskId}/activity": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 실행 중 도구 사용 보고 (Executor)
+ * @description Executor가 Claude 출력에서 도구 사용을 뽑아 2초마다 묶어 보낸다(최대 50줄). **도구 종류와 대상(작업공간 기준 경로·명령 첫 줄, 300자)만** 받는다 —
+ * 파일 내용·명령 결과·모델 설명은 받지 않는다(설명은 인계 노트의 몫). 열린 실행에만 붙는다(409 `RUN_NOT_OPEN`).
+ * 이벤트가 아니라 `agent_activity`에 쌓이고, 화면 스트림에는 `room` 토픽으로 알린다.
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @example {{TASK_BE}} */
+ taskId: components["parameters"]["TaskId"];
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ /**
+ * @example {
+ * "items": [
+ * {
+ * "kind": "read",
+ * "target": "src/routes/auth.ts"
+ * },
+ * {
+ * "kind": "edit",
+ * "target": "src/routes/auth.ts"
+ * },
+ * {
+ * "kind": "run",
+ * "target": "npm test"
+ * }
+ * ]
+ * }
+ */
+ "application/json": {
+ items: {
+ /** @enum {string} */
+ kind: "read" | "edit" | "write" | "run" | "search" | "other";
+ target: string;
+ }[];
};
};
- /** @description 대표가 아니거나 다른 조직 */
- 403: {
+ };
+ responses: {
+ /** @description 기록함 */
+ 201: {
headers: {
[name: string]: unknown;
};
content: {
- /**
- * @example {
- * "error": {
- * "code": "NOT_REPRESENTATIVE",
- * "message": "this action requires the organization representative"
- * }
- * }
- */
- "application/json": unknown;
+ "application/json": {
+ data: {
+ recorded: number;
+ };
+ };
};
};
- /** @description 끝났거나 멈춘 프로젝트 */
+ /** @description 열린 실행이 없음 */
409: {
headers: {
[name: string]: unknown;
@@ -5070,35 +6417,8 @@ export interface paths {
/**
* @example {
* "error": {
- * "code": "PROJECT_NOT_OPEN",
- * "message": "project is halted; specs and tasks can be added only while planning or active"
- * }
- * }
- */
- "application/json": unknown;
- };
- };
- /** @description 검증 위반 전부 */
- 422: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- /**
- * @example {
- * "error": {
- * "code": "PLAN_INVALID",
- * "message": "명세·태스크 검증 실패 2건",
- * "details": [
- * {
- * "where": "tasks[task]",
- * "message": "레포 … 는 이 프로젝트에 연결돼 있지 않다 (연결된 레포: acme/study-api)"
- * },
- * {
- * "where": "tasks[task]",
- * "message": "구현(IMPLEMENT) 태스크에는 명세가 필요하다 (명세 없이 만들 수 있는 건 INTEGRATION·REWORK)"
- * }
- * ]
+ * "code": "RUN_NOT_OPEN",
+ * "message": "no open run for task ..."
* }
* }
*/
@@ -5113,23 +6433,22 @@ export interface paths {
patch?: never;
trace?: never;
};
- "/tasks/{taskId}/briefing": {
+ "/tasks/{taskId}/resume": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
+ get?: never;
+ put?: never;
/**
- * 태스크 브리핑 — 프롬프트·settings.json 재료 한 번에
- * @description Executor는 팀원 노트북에서 돌아 DB를 직접 못 본다. 여러 엔드포인트를 조합하게 하면 조합 방식이
- * Executor마다 갈리므로 서버가 한 번에 준다 — 명세, 서버가 렌더한 인계 노트 블록(`notesBlock`),
- * 쓸 수 있는 경로, `.claude/settings.json`에 그대로 깔 `claudeSettings`, 그리고 V2가 돌릴 `specTests`.
- *
- * **CLAIM 전에도 받을 수 있다.** Executor가 작업공간을 먼저 만들고, claim은 모델이 도구로 하기 때문이다.
- * 남의 역할 태스크는 403이다 — 명세·노트를 미리 보지 못하게 한다.
+ * 멈춘 태스크 재개 (대표 전용)
+ * @description 에이전트가 제출 없이 멈춘 태스크(BLOCKED·`AGENT_STOPPED`)를 원인을 해결한 뒤 다시 돌린다. READY로 돌아가고 담당이 비어
+ * 같은 역할의 에이전트가 다시 가져간다(룸에 "다시 실행해 주세요 (재개)"). **재시도 횟수는 그대로다** — 권한·환경 탓은 에이전트 실패가 아니다.
+ * 멈춘 상태가 아니면 409 `TASK_NOT_STOPPED`.
*/
- get: {
+ post: {
parameters: {
query?: never;
header?: never;
@@ -5139,72 +6458,67 @@ export interface paths {
};
cookie?: never;
};
- requestBody?: never;
+ requestBody?: {
+ content: {
+ /**
+ * @example {
+ * "note": "python 실행을 허용했습니다"
+ * }
+ */
+ "application/json": {
+ /** @description 무엇을 해결했는지(룸에 보인다) */
+ note?: string | null;
+ };
+ };
+ };
responses: {
- /** @description OK */
+ /** @description 재개됨(READY) */
200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ data: components["schemas"]["Task"];
+ };
+ };
+ };
+ /** @description 대표가 아님 */
+ 403: {
headers: {
[name: string]: unknown;
};
content: {
/**
* @example {
- * "data": {
- * "task": {
- * "id": "{{TASK_BE}}",
- * "title": "T-001 참여 신청 API 구현",
- * "state": "READY"
- * },
- * "repo": {
- * "id": "{{REPO_API}}",
- * "fullName": "acme/study-api",
- * "defaultBranch": "main",
- * "devBranch": "dev",
- * "cloneUrl": null
- * },
- * "spec": {
- * "featureKey": "F-03",
- * "title": "참여 신청 API",
- * "content": "WHEN 정원이 찬 스터디에 신청하면 THEN 409를 반환한다"
- * },
- * "notesBlock": "[인계 노트]\n#1 [명세 이탈] quota check moved to service\n · handler was too late\n 영향: contracts/F-03.yaml",
- * "writablePaths": [
- * {
- * "pathPattern": "src/**",
- * "access": "write",
- * "ownerRole": "BACKEND"
- * }
- * ],
- * "claudeSettings": {
- * "permissions": {
- * "deny": [
- * "Read(**\/.env*)",
- * "Read(!**\/.env.example)",
- * "Edit(/contracts/**)",
- * "Edit(**\/secrets/**)"
- * ]
- * }
- * },
- * "specTests": [
- * {
- * "id": "5f0e...",
- * "criterion": "정원이 찬 스터디 신청은 409다",
- * "lockedAt": "2026-09-21T00:00:00.000Z"
- * }
- * ],
- * "policyHash": "{{POLICY_HASH}}"
+ * "error": {
+ * "code": "NOT_REPRESENTATIVE",
+ * "message": "only the representative can resume a stopped task"
* }
* }
*/
- "application/json": {
- data: components["schemas"]["TaskBriefing"];
- };
+ "application/json": unknown;
+ };
+ };
+ /** @description 멈춘 태스크가 아님 */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "error": {
+ * "code": "TASK_NOT_STOPPED",
+ * "message": "task ... is not stopped (state CLAIMED, reason -)"
+ * }
+ * }
+ */
+ "application/json": unknown;
};
};
};
};
- put?: never;
- post?: never;
delete?: never;
options?: never;
head?: never;
@@ -5595,6 +6909,11 @@ export interface components {
createdBy: string;
/** Format: date-time */
createdAt: string;
+ /**
+ * @description 질문 중계(에이전트가 다른 역할에게 AskUserQuestion으로 묻기). 기본 켜짐. 끄면 에이전트는 묻지 않고 가정을 GOTCHA 노트("가정함 — <역할> 확인 필요")로 남기고 진행한다.
+ * 대표가 시작(G1) 전까지 `PATCH /projects/{projectId}/settings`로 바꿀 수 있다.
+ */
+ questionRelay: boolean;
};
RepoListItem: {
/** Format: uuid */
@@ -5622,12 +6941,16 @@ export interface components {
projectId: string;
projectName: string;
/**
- * @description 지금은 ACTION(정책 판정이 HUMAN·PM_REVIEW인 산출물)만 생긴다
+ * @description - ACTION: 검증은 통과했지만 정책 판정이 HUMAN·PM_REVIEW인 산출물(태스크 하나)
+ * - G3: **통합 확인·완료 승인**(프로젝트 전체). 모든 태스크가 DONE이 되면 생긴다. 승인하면 프로젝트 completed, 반려하면 진행 중 그대로(고칠 태스크를 대표가 만든다)
* @enum {string}
*/
gate: "G1" | "G2" | "G3" | "ACTION";
- /** Format: uuid */
- taskId: string;
+ /**
+ * Format: uuid
+ * @description ACTION이면 그 태스크, G3면 null(프로젝트 전체)
+ */
+ taskId: string | null;
/** @description 지금 태스크 제목(카드의 스냅샷은 payload.taskTitle) */
taskTitle: string | null;
/** @description 지금 태스크 상태 */
@@ -5640,7 +6963,9 @@ export interface components {
*/
gateMode: "HUMAN" | "PM_REVIEW" | null;
/**
- * @description 요청 시점의 카드 스냅샷 — taskTitle, artifactId, attempt, commitSha, changedPaths, triggeredActions, gateMode, stages, submittedByAgentId.
+ * @description 요청 시점의 카드 스냅샷.
+ * - ACTION: taskTitle, artifactId, attempt, commitSha, changedPaths, triggeredActions, gateMode, stages, submittedByAgentId.
+ * - G3: integrationChecks(PM이 쓴 확인 항목 문자열 목록), tasks([{ taskId, title, teamRole, repo, branchName, commitSha }] — 머지·실행해 볼 브랜치와 마지막 커밋).
* **backfilled: true**면 승인 기능 이전부터 멈춰 있던 태스크라 지금 DB에서 알 수 있는 값만 있다(stages 등 없음) — "정보 일부 없음"을 표시한다.
*/
payload: {
@@ -5660,6 +6985,88 @@ export interface components {
/** Format: date-time */
decidedAt: string | null;
};
+ AgentQuestion: {
+ /** Format: uuid */
+ id: string;
+ /** Format: uuid */
+ projectId: string;
+ /** Format: uuid */
+ taskId: string;
+ /** Format: uuid */
+ askedByAgentId: string;
+ /** @enum {string} */
+ askerRole: "FRONTEND" | "BACKEND";
+ /**
+ * @description 답할 역할. 서버가 정한다(routedBy에 근거)
+ * @enum {string}
+ */
+ targetRole: "FRONTEND" | "BACKEND";
+ /** @description 대상 역할을 정한 라우터 구현(이름·버전). 기본 'role_rule:opposite'(묻는 쪽의 반대 역할) */
+ routedBy: string;
+ /** @description 라우터 판정 상세 — 라우터 구현을 바꿔 가며 비교하는 근거 */
+ routing: {
+ /** @description 0~1. 규칙 라우터는 null */
+ confidence: number | null;
+ reason: string | null;
+ latencyMs: number;
+ /** @description 라우터가 실패·시간 초과로 규칙 라우터로 대체됐으면 그 사유 */
+ fallback: string | null;
+ };
+ /** @description Claude Code AskUserQuestion 형식 그대로 */
+ questions: ({
+ question: string;
+ header?: string;
+ multiSelect?: boolean;
+ options: ({
+ label: string;
+ description?: string;
+ } & {
+ [key: string]: unknown;
+ })[];
+ } & {
+ [key: string]: unknown;
+ })[];
+ /**
+ * @description pending — 답을 기다림 · agent_answered — 대상 역할 에이전트의 상담 실행이 코드·명세에서 찾은 답(사람 미확인, 묻는 쪽에 이미 감)
+ * answered — 사람이 답함(직접·확인·뒤집기) · expired — 기간 안에 답이 없음(그 질문으로 멈춘 태스크는 ESCALATED)
+ * self_owned — 라우터가 묻는 쪽 자기 소관이라고 판정해 넘기지 않음(targetRole = askerRole)
+ * @enum {string}
+ */
+ status: "pending" | "agent_answered" | "answered" | "expired" | "self_owned";
+ /** @description 상담 실행의 초안. decided[질문]이 false인 것은 코드에 없는 결정 — 사람이 정해야 한다(화면에서 강조) */
+ draft: {
+ answers: {
+ [key: string]: string;
+ };
+ decided: {
+ [key: string]: boolean;
+ };
+ basis: {
+ [key: string]: string[];
+ };
+ } | null;
+ /** Format: uuid */
+ draftedByAgentId: string | null;
+ /** Format: date-time */
+ draftedAt: string | null;
+ /**
+ * @description agent(에이전트가 찾음, 사람 미확인) · human · agent_confirmed(같은 답으로 확인) · human_override(다른 답으로 뒤집음 — 고치는 태스크 자동 생성은 미구현)
+ * @enum {string|null}
+ */
+ answerSource: "agent" | "human" | "agent_confirmed" | "human_override" | null;
+ /** @description 키는 질문 문장 그대로. 값은 선택지 label이거나 자유 문장(근거를 덧붙여도 된다) */
+ answers: {
+ [key: string]: string;
+ } | null;
+ /** Format: uuid */
+ answeredBy: string | null;
+ /** Format: date-time */
+ answeredAt: string | null;
+ /** Format: date-time */
+ expiresAt: string;
+ /** Format: date-time */
+ createdAt: string;
+ };
ProjectEvent: {
/** @description 이벤트 id(정수를 문자열로). 페이지 커서로 쓴다 */
id: string;
@@ -5806,6 +7213,75 @@ export interface components {
*/
rootOwnerRole: "FRONTEND" | "BACKEND" | null;
};
+ CreatedGithubRepo: {
+ /** Format: uuid */
+ id: string;
+ fullName: string;
+ seededPathCount: number;
+ /** @enum {string} */
+ rootOwnerRole: "FRONTEND" | "BACKEND";
+ githubRepoId: number;
+ /** @description GitHub 조직의 기본 브랜치 설정을 따른다(보통 main) */
+ defaultBranch: string;
+ /** @description V3가 커밋을 읽을 곳. 자동으로 채워진다 */
+ cloneUrl: string;
+ };
+ GithubInviteResult: {
+ /** Format: uuid */
+ repoId: string;
+ fullName: string;
+ /**
+ * @description - invited: 초대장을 보냈다. **멤버가 GitHub에서 수락해야** 클론·push할 수 있다
+ * - already_collaborator: 이미 접근할 수 있다
+ * - skipped: 멤버나 대표가 GitHub를 연결하지 않아 보내지 못했다(reason)
+ * - failed: GitHub가 거부·실패했다(reason)
+ * @enum {string}
+ */
+ status: "invited" | "already_collaborator" | "skipped" | "failed";
+ reason?: string;
+ };
+ RoomMessage: {
+ /** @description 피드 커서로도 쓴다(before) */
+ id: string;
+ /** Format: date-time */
+ ts: string;
+ /**
+ * @description - pm: 서버가 지시·판정을 알림(실행해 주세요, 검증 결과, 승인)
+ * - nomos: Executor가 보고한 실행 상태(수령, 구현 시작, 제출, 실행 종료)
+ * - agent: 에이전트가 쓴 도구(읽기·수정·실행) · 인계 노트
+ * @enum {string}
+ */
+ speaker: "pm" | "nomos" | "agent";
+ /** @description 원본 이벤트 타입(TASK_DISPATCHED 등) 또는 ACTIVITY_READ·ACTIVITY_EDIT·ACTIVITY_WRITE·ACTIVITY_RUN·ACTIVITY_SEARCH·ACTIVITY_OTHER. 아이콘용 */
+ type: string;
+ /** Format: uuid */
+ taskId: string | null;
+ taskTitle: string | null;
+ /** @description 화면에 그대로 보여 줄 문장(서버가 만든다) */
+ text: string;
+ };
+ RoomSummary: {
+ /** @enum {string} */
+ role: "FRONTEND" | "BACKEND";
+ /** @description 이 역할에 배정된 에이전트. 없으면 null */
+ agent: {
+ /** Format: uuid */
+ agentId: string;
+ agentName: string;
+ online: boolean;
+ /** Format: date-time */
+ lastSeenAt: string | null;
+ } | null;
+ /** @description 진행 중(CLAIMED·IN_PROGRESS·VERIFYING·AWAITING_APPROVAL) 태스크 */
+ activeTasks: {
+ /** Format: uuid */
+ id: string;
+ title: string;
+ state: string;
+ /** @enum {string|null} */
+ teamRole: "FRONTEND" | "BACKEND" | null;
+ }[];
+ };
RepoSettings: {
/** Format: uuid */
id: string;
@@ -5891,6 +7367,23 @@ export interface components {
* @description 에이전트 주인
*/
userId: string;
+ /**
+ * @description 레포별 **마지막** GitHub 협업자 초대 결과(배정·다시 초대 때 남은 이벤트에서 읽는다 — 새로고침해도 남는다). 초대한 적 없으면 빈 목록
+ * (GitHub 레포가 없는 프로젝트 등). invited는 초대장을 보냈다는 뜻이고 수락 여부는 GET /orgs/{orgId}/members의 isCollaborator로 본다.
+ */
+ githubInvites: {
+ /** Format: uuid */
+ repoId: string;
+ fullName: string;
+ /** @enum {string} */
+ status: "invited" | "already_collaborator" | "skipped" | "failed";
+ reason?: string;
+ /**
+ * Format: date-time
+ * @description 그 초대를 시도한 시각
+ */
+ at: string;
+ }[];
};
ProjectDetail: {
project: components["schemas"]["Project"];
@@ -5917,10 +7410,16 @@ export interface components {
assigneeAgentId: string | null;
branchName: string | null;
/**
- * @description state=BLOCKED일 때만 있다
+ * @description state=BLOCKED일 때만 있다.
+ * AGENT_STOPPED = 에이전트가 제출 없이 멈췄다(권한·환경·응답 끊김). 사유는 이벤트 TASK_BLOCKED(룸 피드)에 있고, 대표가 `POST /tasks/{taskId}/resume`으로 재개한다. 재시도 횟수는 오르지 않는다
* @enum {string|null}
*/
- blockedReason: "DISPUTE" | "DEPENDENCY" | "QUESTION" | null;
+ blockedReason: "DISPUTE" | "DEPENDENCY" | "QUESTION" | "AGENT_STOPPED" | null;
+ /**
+ * Format: date-time
+ * @description 마지막 수령 시각. 화면의 "수령 후 경과"에 쓴다(CLAIMED·IN_PROGRESS일 때 의미가 있다)
+ */
+ claimedAt: string | null;
/** @description FAIL마다 오른다. 3이면 ESCALATED */
retryCount: number;
};
@@ -6082,13 +7581,21 @@ export interface components {
repo: string;
/** @enum {string|null} */
teamRole: "FRONTEND" | "BACKEND" | null;
- /** @enum {string} */
+ /**
+ * @description PM은 INTEGRATION을 내지 않는다(내면 교정 대상). 옛 초안에는 있을 수 있다
+ * @enum {string}
+ */
kind: "IMPLEMENT" | "INTEGRATION" | "REWORK";
/** @description 명세 featureKey */
spec: string | null;
/** @description 먼저 끝나야 하는 태스크의 ref */
dependsOn: string[];
}[];
+ /**
+ * @description 통합 확인 항목. PM은 INTEGRATION 태스크를 만들지 않고(에이전트에게는 선행 코드·상대 레포·실행 수단이 없다) 여기에 적는다.
+ * 모든 태스크가 DONE이 되면 대표의 **G3 카드**(통합 확인·완료 승인)에 그대로 들어간다. 이 필드 이전에 만든 초안에는 없다.
+ */
+ integrationChecks?: string[];
};
/** @description 중계 모드에서 pm-worker가 실행할 모델 호출 한 건. 서버가 만든 지침·프롬프트·출력 스키마를 그대로 실행한다 */
PmRelayJob: {
@@ -6246,6 +7753,14 @@ export interface components {
};
specTests: components["schemas"]["SpecTest"][];
policyHash: string;
+ /** @description 프로젝트의 질문 중계 스위치. 꺼져 있으면 Executor가 권한 도구(AskUserQuestion)를 붙이지 않는다 */
+ questionRelay: boolean;
+ /** @description 이 태스크에서 물어 받은 답 — 질문 때문에 멈췄다 재개한 실행이 읽는다. source=agent는 상대 에이전트가 코드에서 찾은 답(사람 미확인) */
+ answeredQuestions: {
+ question: string;
+ answer: string;
+ source: string;
+ }[];
/** @description 직전 제출이 대표에게 반려됐으면 그 사유. 반려되면 태스크가 READY로 돌아오고 같은 태스크 브랜치에서 이어서 고친다 */
lastRejection: {
/** Format: uuid */
diff --git a/src/widgets/account-settings/AccountSettingsView.tsx b/src/widgets/account-settings/AccountSettingsView.tsx
index 4fec0a1..03961e5 100644
--- a/src/widgets/account-settings/AccountSettingsView.tsx
+++ b/src/widgets/account-settings/AccountSettingsView.tsx
@@ -88,7 +88,8 @@ function GithubSection({ githubLogin }: { githubLogin: string | null }) {
GitHub
- 연동하면 조직 멤버 목록에서 레포 협업자인지 확인되고, 레포를 연결할 때 GitHub 목록에서 고를 수 있습니다.
+ 연동해야 프로젝트에 배정될 때 레포 협업자로 자동 초대됩니다. 대표가 연동하면 레포 목록 조회·새 레포
+ 만들기·협업자 확인이 대표의 GitHub 연결로 동작합니다.
{githubLogin && state.step !== "connected" ? (
diff --git a/src/widgets/app-shell/AppShell.tsx b/src/widgets/app-shell/AppShell.tsx
index 111a84f..5997297 100644
--- a/src/widgets/app-shell/AppShell.tsx
+++ b/src/widgets/app-shell/AppShell.tsx
@@ -2,13 +2,12 @@
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
-import { useEffect, useMemo, useState, type ReactNode } from "react";
+import { useEffect, useState, type ReactNode } from "react";
import {
Activity,
BookOpen,
Building2,
ChevronDown,
- Eye,
FileText,
Inbox,
LayoutDashboard,
@@ -26,8 +25,10 @@ import { useProjectStream, type Schemas } from "@/shared/api";
import { Avatar, Badge, Kbd, Logo, MockBadge } from "@/shared/ui";
import { cn } from "@/shared/lib/format";
import { StatusDot } from "@/entities/agent";
+import { useProjectApprovals } from "@/entities/approval";
import { ProjectErrorView, ProjectStatusBadge, useProject, useProjects } from "@/entities/project";
-import { ROOM_META, type Room } from "@/entities/room";
+import { useMyQuestions } from "@/entities/question";
+import { ROLE_ROOM, ROOM_META, useRooms } from "@/entities/room";
import { AccountMenu, RoleBadge, displayName, useCurrentUser } from "@/entities/user";
import { useApp, useProject as useMockProject } from "@/lib/store";
import { CommandSearch } from "./CommandSearch";
@@ -189,52 +190,23 @@ function SidebarContent({
const user = useCurrentUser().me!;
const isRep = user.orgRole === "REPRESENTATIVE";
const projects = useProjects(project.orgId);
- // Room·받은 편지함은 아직 API가 없어 목업 스토어를 읽는다. 실제 프로젝트 id는 목업에 없으므로 대개 비어 있다
+ // 데모 역할 전환만 목업 스토어를 읽는다. 실제 프로젝트 id는 목업에 없으므로 대개 비어 있다
const { state, me, actions } = useApp();
const mock = useMockProject(projectId);
- const { myRole, visibleRooms, tasks, agentFor, canWrite } = mock;
+ const rooms = useRooms(projectId).data ?? [];
+ // 내가 답할 수 있는 질문(대표는 전부, 팀원은 자기 역할 대상)과, 대표가 결정할 승인
+ const myQuestions = (useMyQuestions().data ?? []).filter((q) => q.question.projectId === projectId);
+ const approvals = useProjectApprovals(projectId).data ?? [];
+ const inboxCount = myQuestions.length + (isRep ? approvals.length : 0);
const pathname = usePathname();
const router = useRouter();
const [projOpen, setProjOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
- const inboxCount = useMemo(() => {
- if (!me || !mock.project) return 0;
- const roomIds = new Set(visibleRooms.map((r) => r.id));
- return state.messages.filter((m) => {
- if (!roomIds.has(m.roomId)) return false;
- const c = m.card;
- if (!c) return false;
- const room = visibleRooms.find((r) => r.id === m.roomId)!;
- if (c.kind === "spec" && c.status === "pending" && myRole === "OWNER") return true;
- if (c.kind === "report" && c.status === "pending" && myRole === "OWNER") return true;
- if (c.kind === "question" && !c.answer && canWrite(room)) return true;
- if (c.kind === "repo" && c.status === "pending" && canWrite(room)) return true;
- return false;
- }).length;
- }, [state.messages, visibleRooms, myRole, me, mock.project, canWrite]);
-
const base = `/p/${projectId}`;
const is = (p: string) => pathname === p;
const startsWith = (p: string) => pathname.startsWith(p);
- const roomStatus = (room: Room) => {
- if (room.type === "OWNER") {
- const pending = state.messages.some(
- (m) =>
- m.roomId === room.id &&
- ((m.card?.kind === "spec" && m.card.status === "pending") ||
- (m.card?.kind === "report" && m.card.status === "pending")),
- );
- return pending ? : null;
- }
- const agent = agentFor(room.type);
- const waiting = tasks.some((t) => t.role === room.type && t.state === "WAITING_HUMAN");
- if (waiting) return ;
- if (!agent) return null;
- return ;
- };
-
const myAgentName = detail.members.find((m) => m.userId === user.userId)?.agentName;
return (
@@ -343,32 +315,22 @@ function SidebarContent({
onNavigate={onNavigate}
/>
)}
- {/* 나머지 Room은 API가 없어 목업 스토어를 읽는다 */}
- {visibleRooms.length === 0 && (
-