Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
11 commits
Select commit Hold shift + click to select a range
06c592f
Feat: 역할별 룸 피드 — 서버의 "실행해 주세요"부터 검증 결과까지, Executor 실행·도구 사용 보고 (CLI 0…
oyeonghun Oct 8, 2026
3e485d1
Fix: 룸 — 실제 실행에서 드러난 것 반영(실행 시작은 잡을 수 있는 태스크도, 작업공간 경로 가리기, 겹치는 줄 정리)
oyeonghun Oct 8, 2026
e8ed46a
Feat: 제출 없이 멈춘 태스크는 BLOCKED(AGENT_STOPPED) — 사유·거부 명령을 남기고 대표가 재개 (운영…
oyeonghun Oct 8, 2026
fa3d0b1
Fix: 이벤트 로그 정렬이 문자열 순서였던 것 — ORDER BY가 SELECT 별칭(id::text)을 정렬했다 (운영 …
oyeonghun Oct 8, 2026
06e7931
Fix: GitHub 레포 목록·협업자 확인을 조직 대표의 토큰으로 — 서버 공용 PAT 제거, 직접 입력 연결도 githu…
oyeonghun Oct 8, 2026
b42d3ba
Feat: 의존성 설치는 Executor가, 시험 실행기 허용, 프롬프트에 작업 환경 명시 (운영 테스트 4-4)
oyeonghun Oct 8, 2026
6a40eb2
Feat: 통합 확인은 대표가 — PM은 INTEGRATION 대신 확인 항목, 모든 태스크 DONE이면 G3 카드 (운영 …
oyeonghun Oct 8, 2026
8c2499c
Feat: package.json 의존성 추가를 dep:add로 — 서버 검증이 커밋 전후 내용을 읽어 판정을 올린다 (운영…
oyeonghun Oct 8, 2026
37528e5
Merge pull request #27 from Capstone-NOMOS/feat/ops-test-fixes
oyeonghun Oct 8, 2026
ce6bbd1
Feat: 멤버 정보에 레포별 마지막 GitHub 초대 결과 — 새로고침해도 남게 (FE 보고)
oyeonghun Oct 8, 2026
69a94fa
Merge pull request #28 from Capstone-NOMOS/feat/member-invite-status
oyeonghun Oct 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 52 additions & 8 deletions CLAUDE.md

Large diffs are not rendered by default.

38 changes: 38 additions & 0 deletions docs/erd.dbml
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,39 @@ Table disputes {
Note: 'PM은 verdict를 뒤집을 수 없음'
}

Table task_dispatches {
task_id uuid [pk, not null]
attempt integer [pk, not null, note: '그때의 tasks.retry_count. 재시도로 다시 READY가 되면 새 행']
dispatched_at timestamptz [not null, default: `now()`]

Note: '''
★ 017 룸: 서버(PM)가 "이 태스크를 실행해 주세요"를 보낸 기록. 가져갈 수 있게 된 순간(시작됨·READY·담당 없음·선행 완료) attempt마다 한 번.
tasksChanged 신호마다 계산하고, PK가 중복을 막는다. 새로 들어간 행에만 TASK_DISPATCHED(system:dispatcher)를 같은 트랜잭션에 남긴다.
CHECK attempt >= 0
'''
}

Table agent_activity {
id bigserial [pk]
project_id uuid [not null]
task_id uuid [not null]
agent_id uuid [not null]
kind text [not null, note: 'read | edit | write | run | search | other']
target text [not null, note: '작업공간 기준 경로 또는 명령 첫 줄, 1~300자']
ts timestamptz [not null, default: `now()`]

indexes {
(project_id, id)
}
Note: '''
★ 017 룸: 에이전트가 실행 중에 쓴 도구 한 줄씩(Executor가 Claude stream-json에서 뽑아 보낸다).
상태 변화가 아니라 events에 넣지 않는다. 파일 내용·명령 결과·모델 설명은 받지 않는다(설명은 인계 노트의 몫).
열린 실행(AGENT_RUN_STARTED 뒤 AGENT_RUN_ENDED 전)에만 붙는다.
agent_activity_kind_chk kind IN ('read','edit','write','run','search','other')
agent_activity_target_chk char_length(target) BETWEEN 1 AND 300
'''
}

Table approvals {
id uuid [pk, default: `gen_random_uuid()`]
project_id uuid [not null]
Expand Down Expand Up @@ -800,6 +833,11 @@ Ref: disputes.raised_by > agents.id
Ref: disputes.target_task_id > tasks.id
Ref: disputes.blocking_task_id > tasks.id

Ref: task_dispatches.task_id > tasks.id
Ref: agent_activity.project_id > projects.id
Ref: agent_activity.task_id > tasks.id
Ref: agent_activity.agent_id > agents.id

Ref: approvals.project_id > projects.id [delete: cascade]
Ref: approvals.decided_by > users.id
Ref: approvals.artifact_id > artifacts.id
Expand Down
3 changes: 1 addition & 2 deletions docs/manual-test.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,6 @@ cp .env.example .env
| `APP_BASE_URL` | ❌ | — | **두 값으로 나뉘었다.** 남아 있으면 서버가 뜨지 않는다 — 지울 것 |
| `DOCS_ENABLED` | | `true` | 로컬 개발은 켜짐, 그 외 꺼짐 |
| `CORS_ALLOWED_ORIGINS` | | `http://localhost:3001` | CORS 헤더 없음(같은 오리진만) |
| `GITHUB_TOKEN` | | GitHub PAT | 레포 목록 조회가 **빈 배열**을 반환한다 (500이 아니다) |
| `GITHUB_CLIENT_ID` · `GITHUB_CLIENT_SECRET` | | OAuth App 값 | GitHub Device Flow 두 API만 502 |
| `SECRET_ENCRYPTION_KEY` | | base64 32바이트 | Device Flow **승인 완료 시점**에만 실패 |
| `KMS_KEY_ID` | | **비워둘 것** | — |
Expand Down Expand Up @@ -363,7 +362,7 @@ curl -s $BASE/orgs/$ORG_ID/github/repos -H "Authorization: Bearer $TOKEN"
curl -s $BASE/orgs/$ORG_ID/members -H "Authorization: Bearer $TOKEN"
```

`GITHUB_TOKEN`이 없으면 레포 목록은 **빈 배열**이고, 멤버 목록에서는 `isCollaborator` 필드가 **생략**된다
대표가 GitHub를 연결하지 않았으면 레포 목록은 **빈 배열**이고, 멤버 목록에서는 `isCollaborator` 필드가 **생략**된다(GitHub 호출은 전부 대표의 토큰으로 한다 — 서버 공용 PAT는 없다)
(`false`로 채우지 않는다 — "확인 안 됨"과 "권한 없음"은 다르다).
시드 계정은 이미 조직에 속해 있으므로 `POST /api/orgs`는 409가 정상이다.

Expand Down
322 changes: 310 additions & 12 deletions docs/openapi.yaml

Large diffs are not rendered by default.

51 changes: 51 additions & 0 deletions migrations/017_rooms.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
-- Up Migration

-- 룸(프로젝트 × 역할)의 피드 재료 두 가지. 룸 자체는 테이블이 없다 — 역할당 에이전트가 하나라 구성이 저절로 정해진다.

-- ① 서버(PM)가 "이 태스크를 실행해 주세요"를 보낸 기록. 가져갈 수 있게 된 순간(READY·담당 없음·선행 완료·시작됨) 한 번씩.
-- 재시도로 다시 READY가 되면 attempt(=그때의 retry_count)가, 대표가 재개하면 resumes(=TASK_RESUMED 수)가 달라 다시 보낸다. PK가 중복을 막는다 —
-- tasksChanged는 여러 번·동시에 불릴 수 있으므로 "한 번만"을 DB가 보장해야 한다. 이벤트(TASK_DISPATCHED)는 INSERT된 행에만 남긴다.
CREATE TABLE task_dispatches (
task_id uuid NOT NULL REFERENCES tasks(id),
attempt integer NOT NULL CHECK (attempt >= 0),
resumes integer NOT NULL DEFAULT 0 CHECK (resumes >= 0),
dispatched_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (task_id, attempt, resumes)
);

-- ② 에이전트가 실행 중에 쓴 도구 한 줄씩("src/a.ts 읽는 중", "npm test 실행"). Executor가 Claude 출력에서 뽑아 보낸다.
-- 상태 변화가 아니라서 events에 넣지 않는다(양이 많고, P5의 대상이 아니다). 파일 내용·명령 결과·모델 설명은 받지 않는다 —
-- 도구 종류와 대상(경로·명령)만. 설명은 인계 노트의 몫이다.
CREATE TABLE agent_activity (
id bigserial PRIMARY KEY,
project_id uuid NOT NULL REFERENCES projects(id),
task_id uuid NOT NULL REFERENCES tasks(id),
agent_id uuid NOT NULL REFERENCES agents(id),
kind text NOT NULL,
target text NOT NULL,
ts timestamptz NOT NULL DEFAULT now(),

CONSTRAINT agent_activity_kind_chk CHECK (kind IN ('read', 'edit', 'write', 'run', 'search', 'other')),
CONSTRAINT agent_activity_target_chk CHECK (char_length(target) BETWEEN 1 AND 300)
);
CREATE INDEX idx_agent_activity_project ON agent_activity(project_id, id);

-- ③ 에이전트가 제출 없이 멈춘 태스크는 BLOCKED(AGENT_STOPPED)로 둔다 — CLAIMED로 남으면 아무도 다시 가져가지 못하고(운영 테스트에서 영구 정체),
-- READY로 되돌려 재시도 횟수를 깎으면 권한·환경 탓을 에이전트 실패로 센다. 대표가 원인을 해결하고 재개하면 READY.
ALTER TABLE tasks DROP CONSTRAINT tasks_blocked_reason_chk;
ALTER TABLE tasks ADD CONSTRAINT tasks_blocked_reason_chk CHECK (blocked_reason IS NULL OR blocked_reason IN
('DISPUTE', 'DEPENDENCY', 'QUESTION', 'AGENT_STOPPED'));

-- ④ 마지막 수령 시각. 화면의 "수령 후 경과"와, 응답이 끊긴 에이전트를 찾는 감시(watchdog)가 쓴다. 이미 잡혀 있는 태스크는 updated_at으로 채운다.
ALTER TABLE tasks ADD COLUMN claimed_at timestamptz;
UPDATE tasks SET claimed_at = updated_at WHERE state IN ('CLAIMED', 'IN_PROGRESS');

-- Down Migration

ALTER TABLE tasks DROP COLUMN IF EXISTS claimed_at;
ALTER TABLE tasks DROP CONSTRAINT tasks_blocked_reason_chk;
ALTER TABLE tasks ADD CONSTRAINT tasks_blocked_reason_chk CHECK (blocked_reason IS NULL OR blocked_reason IN
('DISPUTE', 'DEPENDENCY', 'QUESTION'));

DROP TABLE IF EXISTS agent_activity;
DROP TABLE IF EXISTS task_dispatches;
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@capstone-nomos/cli",
"version": "0.1.3",
"version": "0.1.4",
"description": "NOMOS 에이전트 CLI — 노트북의 Claude Code를 NOMOS 서버에 연결하고 배정된 태스크를 실행한다",
"type": "module",
"bin": {
Expand Down
5 changes: 5 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { specsRouter } from './routes/specs.js';
import { pmRouter } from './routes/pm.js';
import { repoPathsRouter } from './routes/repo-paths.js';
import { reposRouter } from './routes/repos.js';
import { roomsRouter } from './routes/rooms.js';
import { startDispatchRecorder } from './domain/room/dispatch.js';
import { tasksRouter } from './routes/tasks.js';

export type AppOptions = {
Expand All @@ -41,6 +43,8 @@ export function appOptionsFromEnv(): AppOptions {
// 앱 조립만 한다. listen은 server.ts가 한다 — 통합 테스트가 임의 포트로 띄울 수 있어야 하기 때문이다.
export function createApp(options: AppOptions = appOptionsFromEnv()): Express {
const app = express();
// 가져갈 수 있게 된 태스크마다 "실행해 주세요"(TASK_DISPATCHED)를 남긴다 — 룸 피드의 첫 줄. 한 번만 구독한다.
startDispatchRecorder();
// 운영은 Caddy 한 단 뒤에 있다. 이 설정이 없으면 req.ip가 Caddy 주소로 찍힌다(브라우저 승인 화면의 요청 IP가 쓸모없어진다).
// 1단만 믿는다 — X-Forwarded-For의 맨 오른쪽(Caddy가 붙인 값)을 쓰므로 클라이언트가 앞에 끼운 값에 속지 않는다.
app.set('trust proxy', 1);
Expand Down Expand Up @@ -73,6 +77,7 @@ export function createApp(options: AppOptions = appOptionsFromEnv()): Express {
app.use('/api', projectsRouter);
app.use('/api', specsRouter);
app.use('/api', pmRouter);
app.use('/api', roomsRouter);

// 수동 테스트용 Swagger UI. 켤지와 인증은 DOCS_ENABLED·DOCS_BASIC_AUTH가 정한다(NODE_ENV와 분리).
if (options.docs !== null) {
Expand Down
8 changes: 8 additions & 0 deletions src/bridge/claude-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ export const ALLOWED_BASH_PREFIXES = [
'npm run test',
'npx vitest',
'node --test',
// 시험 실행기(운영 테스트 4-4: python이 막혀 BE가 시험을 한 번도 못 돌리고 거부만 13번). 설치는 Executor가 미리 한다 — 설치 명령은 열지 않는다.
'pnpm test',
'pnpm run test',
'yarn test',
'python -m pytest',
'python -m unittest',
'python3 -m pytest',
'python3 -m unittest',
] as const;

export function allowedBashRules(): string[] {
Expand Down
23 changes: 23 additions & 0 deletions src/bridge/nomos-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,29 @@ export class NomosClient {
};
}

// 룸: Claude 실행 시작·끝, 실행 중 도구 사용(종류와 대상만).
async startRun(taskId: string): Promise<void> {
await this.request('POST', `/api/tasks/${taskId}/runs/start`);
}

async endRun(
taskId: string,
body: {
outcome: 'completed' | 'timeout' | 'failed';
committed: boolean;
durationMs: number;
exitCode: number | null;
lastMessage?: string | null;
deniedCommands?: string[];
},
): Promise<{ submitted: boolean; taskState: string; blocked: boolean }> {
return (await this.request('POST', `/api/tasks/${taskId}/runs/end`, body)) as { submitted: boolean; taskState: string; blocked: boolean };
}

async postActivity(taskId: string, items: { kind: string; target: string }[]): Promise<void> {
await this.request('POST', `/api/tasks/${taskId}/activity`, { items });
}

async reportBranch(taskId: string, branchName: string): Promise<void> {
await this.request('PATCH', `/api/tasks/${taskId}/branch`, { branchName });
}
Expand Down
3 changes: 1 addition & 2 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { z } from 'zod';
// vitest의 test.env가 넘긴 값이 .env보다 우선한다.
//
// test에서도 읽지 않는 이유: vitest.config.ts의 test.env가 필요한 값을 전부 주므로 .env는 불필요하고,
// 읽으면 GITHUB_TOKEN 같은 개발용 값이 테스트로 새어 실제 네트워크 호출이 일어날 수 있다.
// 읽으면 ANTHROPIC_API_KEY 같은 개발용 값이 테스트로 새어 실제 네트워크 호출이 일어날 수 있다.
const DOTENV_SKIPPED = new Set(['production', 'test']);

if (!DOTENV_SKIPPED.has(process.env.NODE_ENV ?? 'development')) {
Expand All @@ -30,7 +30,6 @@ const envSchema = z
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
GITHUB_TOKEN: z.string().optional(),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
// 서버 자신의 공개 주소와 프론트 주소는 다르다(프론트는 Vercel). 초대 링크는 사람이 여는 것이므로
// 프론트 주소로 만든다 — API 주소로 만들면 링크를 연 사람이 JSON 화면을 본다.
Expand Down
77 changes: 73 additions & 4 deletions src/domain/approval/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ export type ApprovalRow = {
// 목록용 — 지금 상태(태스크 제목·상태, 프로젝트 이름)를 함께 붙인다. 카드의 스냅샷은 payload에 따로 있다.
export type ApprovalView = Omit<ApprovalRow, 'subjectId'> & {
projectName: string;
taskId: string;
// ACTION은 그 태스크, G3(통합 확인·완료)는 프로젝트 전체라 null.
taskId: string | null;
taskTitle: string | null;
taskState: string | null;
};
Expand All @@ -51,7 +52,7 @@ function toApproval(row: QueryResultRow): ApprovalRow {

export async function insertApproval(
db: Queryable,
input: { projectId: string; gate: ApprovalGate; subjectId: string; artifactId: string; gateMode: string; payload: Record<string, unknown> },
input: { projectId: string; gate: ApprovalGate; subjectId: string; artifactId: string | null; gateMode: string | null; payload: Record<string, unknown> },
): Promise<ApprovalRow> {
const { rows } = await db.query(
`INSERT INTO approvals (project_id, gate, subject_id, artifact_id, gate_mode, payload)
Expand Down Expand Up @@ -121,12 +122,12 @@ export async function listApprovals(
[filter.orgId ?? null, filter.projectId ?? null, filter.status, filter.limit],
);
return rows.map((row) => {
// subject_id는 ACTION 게이트에서 태스크 id다 — 응답에는 taskId로만 낸다.
// subject_id는 ACTION 게이트에서 태스크 id, G3에서는 프로젝트 id다 — 응답에는 태스크일 때만 taskId로 낸다.
const { subjectId: _subject, ...approval } = toApproval(row);
return {
...approval,
projectName: row.project_name as string,
taskId: row.subject_id as string,
taskId: approval.gate === 'ACTION' ? (row.subject_id as string) : null,
taskTitle: (row.task_title as string | null) ?? null,
taskState: (row.task_state as string | null) ?? null,
};
Expand Down Expand Up @@ -157,3 +158,71 @@ export async function findLatestRejection(
}
: null;
}

// ── G3: 통합 확인·완료 승인 ──────────────────────────────────────────────

// 모든 태스크가 DONE인가(하나 이상 있을 때). 프로젝트가 진행 중(active)일 때만 의미가 있다.
// 프로젝트 행을 먼저 잠그고(FOR NO KEY UPDATE — FOR UPDATE는 외래 키 검사와 충돌한다) **다음 문장에서** 다시 읽는다:
// 두 태스크가 동시에 DONE이 되면 서로의 DONE이 안 보여 둘 다 카드를 안 만들 수 있다. 잠금 뒤 새 문장은 먼저 커밋된 쪽을 본다.
export async function isProjectReadyForRelease(db: Queryable, projectId: string): Promise<boolean> {
await db.query(`SELECT 1 FROM projects WHERE id = $1 FOR NO KEY UPDATE`, [projectId]);
const { rows } = await db.query(
`SELECT p.status = 'active'
AND EXISTS (SELECT 1 FROM tasks t WHERE t.project_id = p.id)
AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.project_id = p.id AND t.state <> 'DONE')
AND NOT EXISTS (SELECT 1 FROM approvals a WHERE a.project_id = p.id AND a.gate = 'G3'
AND (a.decision IS NULL OR a.decision = 'APPROVE')) AS ready
FROM projects p WHERE p.id = $1`,
[projectId],
);
return rows[0]?.ready === true;
}

// 적용된 계획들의 통합 확인 항목(PM이 쓴 것). 옛 초안에는 없다.
export async function listIntegrationChecks(db: Queryable, projectId: string): Promise<string[]> {
const { rows } = await db.query(
`SELECT jsonb_array_elements_text(COALESCE(dag_snapshot->'integrationChecks', '[]'::jsonb)) AS check_text
FROM plans WHERE project_id = $1 AND applied_at IS NOT NULL
ORDER BY applied_at, id`,
[projectId],
);
return rows.map((r) => r.check_text as string);
}

// 카드에 붙일 태스크 요약 — 사람이 머지·실행해 볼 브랜치와 마지막 커밋.
export async function listReleaseTasks(
db: Queryable,
projectId: string,
): Promise<{ taskId: string; title: string; teamRole: string | null; repo: string; branchName: string | null; commitSha: string | null }[]> {
const { rows } = await db.query(
`SELECT t.id, t.title, t.team_role, r.full_name, t.branch_name,
(SELECT ar.commit_sha FROM artifacts ar WHERE ar.task_id = t.id ORDER BY ar.attempt DESC LIMIT 1) AS commit_sha
FROM tasks t JOIN repos r ON r.id = t.repo_id
WHERE t.project_id = $1
ORDER BY t.created_at, t.id`,
[projectId],
);
return rows.map((r) => ({
taskId: r.id as string,
title: r.title as string,
teamRole: (r.team_role as string | null) ?? null,
repo: r.full_name as string,
branchName: (r.branch_name as string | null) ?? null,
commitSha: (r.commit_sha as string | null) ?? null,
}));
}

// 대기 중인 G3는 프로젝트당 하나(uq_approvals_pending_subject). 이미 있으면 아무것도 하지 않고 null — 상태 변경을 롤백시키지 않는다.
export async function insertReleaseApproval(db: Queryable, projectId: string, payload: Record<string, unknown>): Promise<string | null> {
const { rows } = await db.query(
`INSERT INTO approvals (project_id, gate, subject_id, payload) VALUES ($1, 'G3', $1, $2)
ON CONFLICT (subject_id) WHERE decision IS NULL DO NOTHING
RETURNING id`,
[projectId, payload],
);
return (rows[0]?.id as string | undefined) ?? null;
}

export async function markProjectCompleted(db: Queryable, projectId: string): Promise<void> {
await db.query(`UPDATE projects SET status = 'completed' WHERE id = $1 AND status = 'active'`, [projectId]);
}
Loading
Loading